From 5c6578d84e2940b9cfd04798c45e7c8042c3fe0e Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 29 Jan 2025 12:06:18 +0000 Subject: [PATCH 1/8] report organizations in telemetry, test that all relevant resources reference their orgs --- coderd/telemetry/telemetry.go | 33 ++++++++++++++++++++++++++ coderd/telemetry/telemetry_test.go | 37 +++++++++++++++++++++++++----- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 233450c43d943..dc9c55f058903 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -29,6 +29,7 @@ import ( "github.com/coder/coder/v2/buildinfo" clitelemetry "github.com/coder/coder/v2/cli/telemetry" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/codersdk" tailnetproto "github.com/coder/coder/v2/tailnet/proto" @@ -518,6 +519,23 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { } return nil }) + eg.Go(func() error { + // Warning: When an organization is deleted, it's completely removed from + // the database. It will no longer be reported, and there will be no other + // indicator that it was deleted. This requires special handling when + // interpreting the telemetry data later. + // nolint:gocritic // AsSystemRestricted is fine here because it's a read-only operation + // used for telemetry reporting. + orgs, err := r.options.Database.GetOrganizations(dbauthz.AsSystemRestricted(r.ctx), database.GetOrganizationsParams{}) + if err != nil { + return xerrors.Errorf("get organizations: %w", err) + } + snapshot.Organizations = make([]Organization, 0, len(orgs)) + for _, org := range orgs { + snapshot.Organizations = append(snapshot.Organizations, ConvertOrganization(org)) + } + return nil + }) err := eg.Wait() if err != nil { @@ -916,6 +934,14 @@ func ConvertExternalProvisioner(id uuid.UUID, tags map[string]string, provisione } } +func ConvertOrganization(org database.Organization) Organization { + return Organization{ + ID: org.ID, + CreatedAt: org.CreatedAt, + IsDefault: org.IsDefault, + } +} + // Snapshot represents a point-in-time anonymized database dump. // Data is aggregated by latest on the server-side, so partial data // can be sent without issue. @@ -942,6 +968,7 @@ type Snapshot struct { WorkspaceModules []WorkspaceModule `json:"workspace_modules"` Workspaces []Workspace `json:"workspaces"` NetworkEvents []NetworkEvent `json:"network_events"` + Organizations []Organization `json:"organizations"` } // Deployment contains information about the host running Coder. @@ -1457,6 +1484,12 @@ func NetworkEventFromProto(proto *tailnetproto.TelemetryEvent) (NetworkEvent, er }, nil } +type Organization struct { + ID uuid.UUID `json:"id"` + IsDefault bool `json:"is_default"` + CreatedAt time.Time `json:"created_at"` +} + type noopReporter struct{} func (*noopReporter) Report(_ *Snapshot) {} diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index e0cbfd1cfa193..9987c28ad6a8e 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -40,22 +40,33 @@ func TestTelemetry(t *testing.T) { 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, + Provisioner: database.ProvisionerTypeTerraform, + StorageMethod: database.ProvisionerStorageMethodFile, + Type: database.ProvisionerJobTypeTemplateVersionDryRun, + OrganizationID: org.ID, }) _ = dbgen.Template(t, db, database.Template{ - Provisioner: database.ProvisionerTypeTerraform, + 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, }) - _ = dbgen.TemplateVersion(t, db, database.TemplateVersion{}) user := dbgen.User(t, db, database.User{}) - _ = dbgen.Workspace(t, db, database.WorkspaceTable{}) + _ = dbgen.Workspace(t, db, database.WorkspaceTable{ + OrganizationID: org.ID, + }) _ = dbgen.WorkspaceApp(t, db, database.WorkspaceApp{ SharingLevel: database.AppSharingLevelOwner, Health: database.WorkspaceAppHealthDisabled, @@ -112,6 +123,7 @@ func TestTelemetry(t *testing.T) { 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) @@ -128,6 +140,19 @@ func TestTelemetry(t *testing.T) { }) 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() From 2a26b4da15fe18d88401770e48f21450c05e222b Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 29 Jan 2025 12:53:43 +0000 Subject: [PATCH 2/8] add the IDPOrgSync field to telemetry deployment --- coderd/telemetry/telemetry.go | 48 ++++++++++++++++++++++++++++ coderd/telemetry/telemetry_test.go | 50 ++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index dc9c55f058903..8d93b0a1c7b4f 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "database/sql" "encoding/json" "errors" "fmt" @@ -245,6 +246,11 @@ func (r *remoteReporter) deployment() error { return xerrors.Errorf("install source must be <=64 chars: %s", installSource) } + idpOrgSync, err := checkIDPOrgSync(r.ctx, r.options.Database, r.options.DeploymentConfig) + if err != nil { + r.options.Logger.Debug(r.ctx, "check IDP org sync", slog.Error(err)) + } + data, err := json.Marshal(&Deployment{ ID: r.options.DeploymentID, Architecture: sysInfo.Architecture, @@ -264,6 +270,7 @@ func (r *remoteReporter) deployment() error { MachineID: sysInfo.UniqueID, StartedAt: r.startedAt, ShutdownAt: r.shutdownAt, + IDPOrgSync: idpOrgSync, }) if err != nil { return xerrors.Errorf("marshal deployment: %w", err) @@ -285,6 +292,46 @@ func (r *remoteReporter) deployment() error { return nil } +// idpOrgSyncConfig is a subset of +// https://github.com/coder/coder/blob/5c6578d84e2940b9cfd04798c45e7c8042c3fe0e/coderd/idpsync/organization.go#L148 +type idpOrgSyncConfig struct { + Field string `json:"field"` +} + +// checkIDPOrgSync checks if IDP org sync is configured by checking the runtime +// config. It's based on the OrganizationSyncEnabled function from +// enterprise/coderd/enidpsync/organizations.go. It has one distinct difference: +// it doesn't check if the license entitles to the feature, it only checks if the +// feature is configured. +// +// The above function is not used because it's very hard to make it available in +// the telemetry package due to coder/coder package structure and initialization +// order of the coder server. +// +// We don't check license entitlements because it's also hard to do from the +// telemetry package, and the config check is sufficient for telemetry purposes. +// +// While this approach duplicates code, it's simpler than the alternative. +// +// See https://github.com/coder/coder/pull/16323 for more details. +func checkIDPOrgSync(ctx context.Context, db database.Store, values *codersdk.DeploymentValues) (bool, error) { + // key based on https://github.com/coder/coder/blob/5c6578d84e2940b9cfd04798c45e7c8042c3fe0e/coderd/idpsync/idpsync.go#L168 + syncConfigRaw, err := db.GetRuntimeConfig(ctx, "organization-sync-settings") + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // If the runtime config is not set, we check if the deployment config + // has the organization field set. + return values != nil && values.OIDC.OrganizationField != "", nil + } + return false, xerrors.Errorf("get runtime config: %w", err) + } + syncConfig := idpOrgSyncConfig{} + if err := json.Unmarshal([]byte(syncConfigRaw), &syncConfig); err != nil { + return false, xerrors.Errorf("unmarshal runtime config: %w", err) + } + return syncConfig.Field != "", nil +} + // createSnapshot collects a full snapshot from the database. func (r *remoteReporter) createSnapshot() (*Snapshot, error) { var ( @@ -991,6 +1038,7 @@ type Deployment struct { MachineID string `json:"machine_id"` StartedAt time.Time `json:"started_at"` ShutdownAt *time.Time `json:"shutdown_at"` + IDPOrgSync bool `json:"idp_org_sync"` } type APIKey struct { diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 9987c28ad6a8e..603eb193e0050 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -22,7 +22,12 @@ import ( "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/entitlements" + "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/enterprise/coderd/enidpsync" "github.com/coder/coder/v2/testutil" ) @@ -268,6 +273,51 @@ func TestTelemetry(t *testing.T) { 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 + entitled := entitlements.New() + entitled.Modify(func(entitlements *codersdk.Entitlements) { + entitlements.Features[codersdk.FeatureMultipleOrganizations] = codersdk.Feature{ + Entitlement: codersdk.EntitlementEntitled, + Enabled: true, + Limit: nil, + Actual: nil, + } + }) + org, err := db.GetDefaultOrganization(ctx) + require.NoError(t, err) + sync := enidpsync.NewSync(testutil.Logger(t), runtimeconfig.NewManager(), entitled, 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) + require.True(t, sync.OrganizationSyncEnabled(ctx, db)) + deployment, _ = collectSnapshot(t, db, nil) + require.True(t, deployment.IDPOrgSync) + }) } // nolint:paralleltest From 229aac7606a8c53670413de91f58dd5caa9d23b4 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 29 Jan 2025 12:55:31 +0000 Subject: [PATCH 3/8] add comments in org sync related functions in idpsync --- coderd/idpsync/organization.go | 2 ++ enterprise/coderd/enidpsync/organizations.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/coderd/idpsync/organization.go b/coderd/idpsync/organization.go index 12d79bc047776..8b430fe84a3e6 100644 --- a/coderd/idpsync/organization.go +++ b/coderd/idpsync/organization.go @@ -45,6 +45,8 @@ func (s AGPLIDPSync) UpdateOrganizationSettings(ctx context.Context, db database } func (s AGPLIDPSync) OrganizationSyncSettings(ctx context.Context, db database.Store) (*OrganizationSyncSettings, error) { + // If this logic is ever updated, make sure to update the corresponding + // checkIDPOrgSync in coderd/telemetry/telemetry.go. rlv := s.Manager.Resolver(db) orgSettings, err := s.SyncSettings.Organization.Resolve(ctx, rlv) if err != nil { diff --git a/enterprise/coderd/enidpsync/organizations.go b/enterprise/coderd/enidpsync/organizations.go index 313d90fac8a9f..826144afc1492 100644 --- a/enterprise/coderd/enidpsync/organizations.go +++ b/enterprise/coderd/enidpsync/organizations.go @@ -19,6 +19,8 @@ func (e EnterpriseIDPSync) OrganizationSyncEnabled(ctx context.Context, db datab return false } + // If this logic is ever updated, make sure to update the corresponding + // checkIDPOrgSync in coderd/telemetry/telemetry.go. settings, err := e.OrganizationSyncSettings(ctx, db) if err == nil && settings.Field != "" { return true From ab98f7fa89319bd2131c6cd587d1ca1544c89048 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 29 Jan 2025 12:58:17 +0000 Subject: [PATCH 4/8] update comment --- coderd/telemetry/telemetry.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 8d93b0a1c7b4f..2395a424c785e 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -298,11 +298,10 @@ type idpOrgSyncConfig struct { Field string `json:"field"` } -// checkIDPOrgSync checks if IDP org sync is configured by checking the runtime -// config. It's based on the OrganizationSyncEnabled function from -// enterprise/coderd/enidpsync/organizations.go. It has one distinct difference: -// it doesn't check if the license entitles to the feature, it only checks if the -// feature is configured. +// checkIDPOrgSync inspects the server flags and the runtime config. It's based on +// the OrganizationSyncEnabled function from enterprise/coderd/enidpsync/organizations.go. +// It has one distinct difference: it doesn't check if the license entitles to the +// feature, it only checks if the feature is configured. // // The above function is not used because it's very hard to make it available in // the telemetry package due to coder/coder package structure and initialization From 23b850591871272d011e1decad6f89ef37220239 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 29 Jan 2025 13:01:31 +0000 Subject: [PATCH 5/8] fix importing enterprise code from agpl --- coderd/telemetry/telemetry_test.go | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 603eb193e0050..06ad2be47af1a 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -22,12 +22,10 @@ import ( "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/entitlements" "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/enterprise/coderd/enidpsync" "github.com/coder/coder/v2/testutil" ) @@ -294,18 +292,9 @@ func TestTelemetry(t *testing.T) { require.True(t, deployment.IDPOrgSync) // 3. Org sync settings set in runtime config - entitled := entitlements.New() - entitled.Modify(func(entitlements *codersdk.Entitlements) { - entitlements.Features[codersdk.FeatureMultipleOrganizations] = codersdk.Feature{ - Entitlement: codersdk.EntitlementEntitled, - Enabled: true, - Limit: nil, - Actual: nil, - } - }) org, err := db.GetDefaultOrganization(ctx) require.NoError(t, err) - sync := enidpsync.NewSync(testutil.Logger(t), runtimeconfig.NewManager(), entitled, idpsync.DeploymentSyncSettings{}) + sync := idpsync.NewAGPLSync(testutil.Logger(t), runtimeconfig.NewManager(), idpsync.DeploymentSyncSettings{}) err = sync.UpdateOrganizationSettings(ctx, db, idpsync.OrganizationSyncSettings{ Field: "organizations", Mapping: map[string][]uuid.UUID{ @@ -314,7 +303,6 @@ func TestTelemetry(t *testing.T) { AssignDefault: true, }) require.NoError(t, err) - require.True(t, sync.OrganizationSyncEnabled(ctx, db)) deployment, _ = collectSnapshot(t, db, nil) require.True(t, deployment.IDPOrgSync) }) From 0baa504393db6737ab3cdc110b406a5d49c57350 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 29 Jan 2025 13:11:03 +0000 Subject: [PATCH 6/8] update comment --- coderd/telemetry/telemetry.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 2395a424c785e..569557fb0dfb3 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -308,7 +308,7 @@ type idpOrgSyncConfig struct { // order of the coder server. // // We don't check license entitlements because it's also hard to do from the -// telemetry package, and the config check is sufficient for telemetry purposes. +// telemetry package, and the config check should be sufficient for telemetry purposes. // // While this approach duplicates code, it's simpler than the alternative. // From 0d3a9a6b221c1e0e3358c5b4358bcbbba482f9e4 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 29 Jan 2025 13:43:24 +0000 Subject: [PATCH 7/8] remove unnecessary dbauthz.AsSystemRestricted --- coderd/telemetry/telemetry.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 569557fb0dfb3..2aa47976ff1f1 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -30,7 +30,6 @@ import ( "github.com/coder/coder/v2/buildinfo" clitelemetry "github.com/coder/coder/v2/cli/telemetry" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/codersdk" tailnetproto "github.com/coder/coder/v2/tailnet/proto" @@ -570,9 +569,7 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { // the database. It will no longer be reported, and there will be no other // indicator that it was deleted. This requires special handling when // interpreting the telemetry data later. - // nolint:gocritic // AsSystemRestricted is fine here because it's a read-only operation - // used for telemetry reporting. - orgs, err := r.options.Database.GetOrganizations(dbauthz.AsSystemRestricted(r.ctx), database.GetOrganizationsParams{}) + orgs, err := r.options.Database.GetOrganizations(r.ctx, database.GetOrganizationsParams{}) if err != nil { return xerrors.Errorf("get organizations: %w", err) } From 07057b173c99ee9cb799a190b43330b03db373d6 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 29 Jan 2025 13:54:39 +0000 Subject: [PATCH 8/8] make the IDPOrgSync field a pointer to ensure backwards compatibility --- coderd/telemetry/telemetry.go | 6 ++++-- coderd/telemetry/telemetry_test.go | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 2aa47976ff1f1..497a1109c7db9 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -269,7 +269,7 @@ func (r *remoteReporter) deployment() error { MachineID: sysInfo.UniqueID, StartedAt: r.startedAt, ShutdownAt: r.shutdownAt, - IDPOrgSync: idpOrgSync, + IDPOrgSync: &idpOrgSync, }) if err != nil { return xerrors.Errorf("marshal deployment: %w", err) @@ -1034,7 +1034,9 @@ type Deployment struct { MachineID string `json:"machine_id"` StartedAt time.Time `json:"started_at"` ShutdownAt *time.Time `json:"shutdown_at"` - IDPOrgSync bool `json:"idp_org_sync"` + // While IDPOrgSync will always be set, it's nullable to make + // the struct backwards compatible with older coder versions. + IDPOrgSync *bool `json:"idp_org_sync"` } type APIKey struct { diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 06ad2be47af1a..b892e28e89d58 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -278,7 +278,7 @@ func TestTelemetry(t *testing.T) { // 1. No org sync settings deployment, _ := collectSnapshot(t, db, nil) - require.False(t, deployment.IDPOrgSync) + require.False(t, *deployment.IDPOrgSync) // 2. Org sync settings set in server flags deployment, _ = collectSnapshot(t, db, func(opts telemetry.Options) telemetry.Options { @@ -289,7 +289,7 @@ func TestTelemetry(t *testing.T) { } return opts }) - require.True(t, deployment.IDPOrgSync) + require.True(t, *deployment.IDPOrgSync) // 3. Org sync settings set in runtime config org, err := db.GetDefaultOrganization(ctx) @@ -304,7 +304,7 @@ func TestTelemetry(t *testing.T) { }) require.NoError(t, err) deployment, _ = collectSnapshot(t, db, nil) - require.True(t, deployment.IDPOrgSync) + require.True(t, *deployment.IDPOrgSync) }) }