diff --git a/cli/server.go b/cli/server.go index 63a3976b37870..2696dd4616117 100644 --- a/cli/server.go +++ b/cli/server.go @@ -29,6 +29,7 @@ import ( "github.com/coreos/go-systemd/daemon" embeddedpostgres "github.com/fergusstrange/embedded-postgres" "github.com/google/go-github/v43/github" + "github.com/google/uuid" "github.com/pion/turn/v2" "github.com/pion/webrtc/v3" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -53,6 +54,7 @@ import ( "github.com/coder/coder/coderd/database/databasefake" "github.com/coder/coder/coderd/devtunnel" "github.com/coder/coder/coderd/gitsshkey" + "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/tracing" "github.com/coder/coder/coderd/turnconn" "github.com/coder/coder/codersdk" @@ -81,6 +83,7 @@ func server() *cobra.Command { oauth2GithubClientSecret string oauth2GithubAllowedOrganizations []string oauth2GithubAllowSignups bool + telemetryURL string tlsCertFile string tlsClientCAFile string tlsClientAuth string @@ -134,6 +137,7 @@ func server() *cobra.Command { } config := createConfig(cmd) + builtinPostgres := false // Only use built-in if PostgreSQL URL isn't specified! if !inMemoryDatabase && postgresURL == "" { var closeFunc func() error @@ -142,6 +146,7 @@ func server() *cobra.Command { if err != nil { return err } + builtinPostgres = true defer func() { // Gracefully shut PostgreSQL down! _ = closeFunc() @@ -253,6 +258,7 @@ func server() *cobra.Command { SSHKeygenAlgorithm: sshKeygenAlgorithm, TURNServer: turnServer, TracerProvider: tracerProvider, + Telemetry: telemetry.NewNoop(), } if oauth2GithubClientSecret != "" { @@ -285,6 +291,44 @@ func server() *cobra.Command { } } + deploymentID, err := options.Database.GetDeploymentID(cmd.Context()) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + if err != nil { + return xerrors.Errorf("get deployment id: %w", err) + } + if deploymentID == "" { + deploymentID = uuid.NewString() + err = options.Database.InsertDeploymentID(cmd.Context(), deploymentID) + if err != nil { + return xerrors.Errorf("set deployment id: %w", err) + } + } + + // Parse the raw telemetry URL! + telemetryURL, err := url.Parse(telemetryURL) + if err != nil { + return xerrors.Errorf("parse telemetry url: %w", err) + } + if !inMemoryDatabase || cmd.Flags().Changed("telemetry-url") { + options.Telemetry, err = telemetry.New(telemetry.Options{ + BuiltinPostgres: builtinPostgres, + DeploymentID: deploymentID, + Database: options.Database, + Logger: logger.Named("telemetry"), + URL: telemetryURL, + GitHubOAuth: oauth2GithubClientID != "", + Prometheus: promEnabled, + STUN: len(stunServers) != 0, + Tunnel: tunnel, + }) + if err != nil { + return xerrors.Errorf("create telemetry reporter: %w", err) + } + defer options.Telemetry.Close() + } + coderAPI := coderd.New(options) client := codersdk.New(localURL) if tlsEnable { @@ -438,6 +482,8 @@ func server() *cobra.Command { <-devTunnelErrChan } + // Ensures a last report can be sent before exit! + options.Telemetry.Close() cmd.Println("Waiting for WebSocket connections to close...") shutdownConns() coderAPI.Close() @@ -485,6 +531,8 @@ func server() *cobra.Command { "Specifies organizations the user must be a member of to authenticate with GitHub.") cliflag.BoolVarP(root.Flags(), &oauth2GithubAllowSignups, "oauth2-github-allow-signups", "", "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS", false, "Specifies whether new users can sign up with GitHub.") + cliflag.StringVarP(root.Flags(), &telemetryURL, "telemetry-url", "", "CODER_TELEMETRY_URL", "https://telemetry.coder.com", "Specifies a URL to send telemetry to.") + _ = root.Flags().MarkHidden("telemetry-url") cliflag.BoolVarP(root.Flags(), &tlsEnable, "tls-enable", "", "CODER_TLS_ENABLE", false, "Specifies if TLS will be enabled") cliflag.StringVarP(root.Flags(), &tlsCertFile, "tls-cert-file", "", "CODER_TLS_CERT_FILE", "", "Specifies the path to the certificate for TLS. It requires a PEM-encoded file. "+ diff --git a/cli/server_test.go b/cli/server_test.go index cb343547f93c1..a7be6b7d021e8 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -8,10 +8,12 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/json" "encoding/pem" "math/big" "net" "net/http" + "net/http/httptest" "net/url" "os" "runtime" @@ -19,12 +21,14 @@ import ( "testing" "time" + "github.com/go-chi/chi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/database/postgres" + "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/codersdk" ) @@ -233,6 +237,37 @@ func TestServer(t *testing.T) { require.ErrorIs(t, <-errC, context.Canceled) require.Error(t, goleak.Find()) }) + t.Run("Telemetry", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + deployment := make(chan struct{}, 64) + snapshot := make(chan *telemetry.Snapshot, 64) + r := chi.NewRouter() + r.Post("/deployment", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + deployment <- struct{}{} + }) + r.Post("/snapshot", func(w http.ResponseWriter, r *http.Request) { + 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) + + root, _ := clitest.New(t, "server", "--in-memory", "--address", ":0", "--telemetry-url", server.URL) + errC := make(chan error) + go func() { + errC <- root.ExecuteContext(ctx) + }() + + <-deployment + <-snapshot + }) } func generateTLSCertificate(t testing.TB) (certPath, keyPath string) { diff --git a/coderd/coderd.go b/coderd/coderd.go index b4d7db305ea1d..c4822145b5a60 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/tracing" "github.com/coder/coder/coderd/turnconn" "github.com/coder/coder/coderd/wsconncache" @@ -54,6 +55,7 @@ type Options struct { ICEServers []webrtc.ICEServer SecureAuthCookie bool SSHKeygenAlgorithm gitsshkey.Algorithm + Telemetry telemetry.Reporter TURNServer *turnconn.Server TracerProvider *sdktrace.TracerProvider } diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 2eb25ccd2091a..5f3500f1072cf 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -28,6 +28,7 @@ import ( "github.com/spf13/afero" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/util/ptr" "cloud.google.com/go/compute/metadata" @@ -166,6 +167,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, *coderd.API) TURNServer: turnServer, APIRateLimit: options.APIRateLimit, Authorizer: options.Authorizer, + Telemetry: telemetry.NewNoop(), }) srv.Config.Handler = coderAPI.Handler if options.IncludeProvisionerD { diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 228d89c2f4444..e41c70b874ed8 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -66,6 +66,8 @@ type fakeQuerier struct { workspaceBuilds []database.WorkspaceBuild workspaceApps []database.WorkspaceApp workspaces []database.Workspace + + deploymentID string } // InTx doesn't rollback data properly for in-memory yet. @@ -128,6 +130,19 @@ func (q *fakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIK return database.APIKey{}, sql.ErrNoRows } +func (q *fakeQuerier) GetAPIKeysLastUsedAfter(_ context.Context, after time.Time) ([]database.APIKey, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + apiKeys := make([]database.APIKey, 0) + for _, key := range q.apiKeys { + if key.LastUsed.After(after) { + apiKeys = append(apiKeys, key) + } + } + return apiKeys, nil +} + func (q *fakeQuerier) DeleteAPIKeyByID(_ context.Context, id string) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -315,7 +330,7 @@ func (q *fakeQuerier) GetAuthorizationUserRoles(_ context.Context, userID uuid.U }, nil } -func (q *fakeQuerier) GetWorkspacesWithFilter(_ context.Context, arg database.GetWorkspacesWithFilterParams) ([]database.Workspace, error) { +func (q *fakeQuerier) GetWorkspaces(_ context.Context, arg database.GetWorkspacesParams) ([]database.Workspace, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -421,6 +436,19 @@ func (q *fakeQuerier) GetWorkspaceAppsByAgentID(_ context.Context, id uuid.UUID) return apps, nil } +func (q *fakeQuerier) GetWorkspaceAppsCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceApp, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + apps := make([]database.WorkspaceApp, 0) + for _, app := range q.workspaceApps { + if app.CreatedAt.After(after) { + apps = append(apps, app) + } + } + return apps, nil +} + func (q *fakeQuerier) GetWorkspaceAppsByAgentIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceApp, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -647,6 +675,19 @@ func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(_ context.Con return database.WorkspaceBuild{}, sql.ErrNoRows } +func (q *fakeQuerier) GetWorkspaceBuildsCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceBuild, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + workspaceBuilds := make([]database.WorkspaceBuild, 0) + for _, workspaceBuild := range q.workspaceBuilds { + if workspaceBuild.CreatedAt.After(after) { + workspaceBuilds = append(workspaceBuilds, workspaceBuild) + } + } + return workspaceBuilds, nil +} + func (q *fakeQuerier) GetOrganizations(_ context.Context) ([]database.Organization, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -870,6 +911,19 @@ func (q *fakeQuerier) GetTemplateVersionsByTemplateID(_ context.Context, arg dat return version, nil } +func (q *fakeQuerier) GetTemplateVersionsCreatedAfter(_ context.Context, after time.Time) ([]database.TemplateVersion, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + versions := make([]database.TemplateVersion, 0) + for _, version := range q.templateVersions { + if version.CreatedAt.After(after) { + versions = append(versions, version) + } + } + return versions, nil +} + func (q *fakeQuerier) GetTemplateVersionByTemplateIDAndName(_ context.Context, arg database.GetTemplateVersionByTemplateIDAndNameParams) (database.TemplateVersion, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -929,6 +983,19 @@ func (q *fakeQuerier) GetParameterSchemasByJobID(_ context.Context, jobID uuid.U return parameters, nil } +func (q *fakeQuerier) GetParameterSchemasCreatedAfter(_ context.Context, after time.Time) ([]database.ParameterSchema, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + parameters := make([]database.ParameterSchema, 0) + for _, parameterSchema := range q.parameterSchemas { + if parameterSchema.CreatedAt.After(after) { + parameters = append(parameters, parameterSchema) + } + } + return parameters, nil +} + func (q *fakeQuerier) GetParameterValueByScopeAndName(_ context.Context, arg database.GetParameterValueByScopeAndNameParams) (database.ParameterValue, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -948,6 +1015,13 @@ func (q *fakeQuerier) GetParameterValueByScopeAndName(_ context.Context, arg dat return database.ParameterValue{}, sql.ErrNoRows } +func (q *fakeQuerier) GetTemplates(_ context.Context) ([]database.Template, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + return q.templates[:], nil +} + func (q *fakeQuerier) GetOrganizationMemberByUserID(_ context.Context, arg database.GetOrganizationMemberByUserIDParams) (database.OrganizationMember, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -1095,6 +1169,19 @@ func (q *fakeQuerier) GetWorkspaceAgentsByResourceIDs(_ context.Context, resourc return workspaceAgents, nil } +func (q *fakeQuerier) GetWorkspaceAgentsCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceAgent, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + workspaceAgents := make([]database.WorkspaceAgent, 0) + for _, agent := range q.provisionerJobAgents { + if agent.CreatedAt.After(after) { + workspaceAgents = append(workspaceAgents, agent) + } + } + return workspaceAgents, nil +} + func (q *fakeQuerier) GetWorkspaceAppByAgentIDAndName(_ context.Context, arg database.GetWorkspaceAppByAgentIDAndNameParams) (database.WorkspaceApp, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -1166,6 +1253,19 @@ func (q *fakeQuerier) GetWorkspaceResourcesByJobID(_ context.Context, jobID uuid return resources, nil } +func (q *fakeQuerier) GetWorkspaceResourcesCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceResource, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + resources := make([]database.WorkspaceResource, 0) + for _, resource := range q.provisionerJobResources { + if resource.CreatedAt.After(after) { + resources = append(resources, resource) + } + } + return resources, nil +} + func (q *fakeQuerier) GetProvisionerJobsByIDs(_ context.Context, ids []uuid.UUID) ([]database.ProvisionerJob, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -1186,6 +1286,19 @@ func (q *fakeQuerier) GetProvisionerJobsByIDs(_ context.Context, ids []uuid.UUID return jobs, nil } +func (q *fakeQuerier) GetProvisionerJobsCreatedAfter(_ context.Context, after time.Time) ([]database.ProvisionerJob, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + jobs := make([]database.ProvisionerJob, 0) + for _, job := range q.provisionerJobs { + if job.CreatedAt.After(after) { + jobs = append(jobs, job) + } + } + return jobs, nil +} + func (q *fakeQuerier) GetProvisionerLogsByIDBetween(_ context.Context, arg database.GetProvisionerLogsByIDBetweenParams) ([]database.ProvisionerJobLog, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -1966,3 +2079,18 @@ func (q *fakeQuerier) InsertAuditLog(_ context.Context, arg database.InsertAudit return alog, nil } + +func (q *fakeQuerier) InsertDeploymentID(_ context.Context, id string) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + q.deploymentID = id + return nil +} + +func (q *fakeQuerier) GetDeploymentID(_ context.Context) (string, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + return q.deploymentID, nil +} diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 0a795c29bea94..b479dc2315a99 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -226,6 +226,11 @@ CREATE TABLE provisioner_jobs ( worker_id uuid ); +CREATE TABLE site_configs ( + key character varying(256) NOT NULL, + value character varying(8192) NOT NULL +); + CREATE TABLE template_versions ( id uuid NOT NULL, template_id uuid, @@ -378,6 +383,9 @@ ALTER TABLE ONLY provisioner_job_logs ALTER TABLE ONLY provisioner_jobs ADD CONSTRAINT provisioner_jobs_pkey PRIMARY KEY (id); +ALTER TABLE ONLY site_configs + ADD CONSTRAINT site_configs_key_key UNIQUE (key); + ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_pkey PRIMARY KEY (id); diff --git a/coderd/database/migrations/000023_site_config.down.sql b/coderd/database/migrations/000023_site_config.down.sql new file mode 100644 index 0000000000000..874d79c94b9b6 --- /dev/null +++ b/coderd/database/migrations/000023_site_config.down.sql @@ -0,0 +1 @@ +DROP TABLE site_configs; diff --git a/coderd/database/migrations/000023_site_config.up.sql b/coderd/database/migrations/000023_site_config.up.sql new file mode 100644 index 0000000000000..70c64e511c2d8 --- /dev/null +++ b/coderd/database/migrations/000023_site_config.up.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS site_configs ( + key varchar(256) NOT NULL UNIQUE, + value varchar(8192) NOT NULL +); diff --git a/coderd/database/migrations/000023_template_created_by_not_null.down.sql b/coderd/database/migrations/000024_template_created_by_not_null.down.sql similarity index 100% rename from coderd/database/migrations/000023_template_created_by_not_null.down.sql rename to coderd/database/migrations/000024_template_created_by_not_null.down.sql diff --git a/coderd/database/migrations/000023_template_created_by_not_null.up.sql b/coderd/database/migrations/000024_template_created_by_not_null.up.sql similarity index 100% rename from coderd/database/migrations/000023_template_created_by_not_null.up.sql rename to coderd/database/migrations/000024_template_created_by_not_null.up.sql diff --git a/coderd/database/models.go b/coderd/database/models.go index 6527fdc5bc8b8..3dbbf5da05be3 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -426,6 +426,11 @@ type ProvisionerJobLog struct { Output string `db:"output" json:"output"` } +type SiteConfig struct { + Key string `db:"key" json:"key"` + Value string `db:"value" json:"value"` +} + type Template struct { ID uuid.UUID `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index ffac6902a13e1..f3108dc8b00ec 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -6,6 +6,7 @@ package database import ( "context" + "time" "github.com/google/uuid" ) @@ -22,12 +23,14 @@ type querier interface { DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) + GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) // GetAuditLogsBefore retrieves `limit` number of audit logs before the provided // ID. GetAuditLogsBefore(ctx context.Context, arg GetAuditLogsBeforeParams) ([]AuditLog, error) // This function returns roles for authorization purposes. Implied member roles // are included. GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) + GetDeploymentID(ctx context.Context) (string, error) GetFileByHash(ctx context.Context, hash string) (File, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) @@ -40,12 +43,14 @@ type querier interface { GetOrganizations(ctx context.Context) ([]Organization, error) GetOrganizationsByUserID(ctx context.Context, userID uuid.UUID) ([]Organization, error) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]ParameterSchema, error) + GetParameterSchemasCreatedAfter(ctx context.Context, createdAt time.Time) ([]ParameterSchema, error) GetParameterValueByScopeAndName(ctx context.Context, arg GetParameterValueByScopeAndNameParams) (ParameterValue, error) GetParameterValuesByScope(ctx context.Context, arg GetParameterValuesByScopeParams) ([]ParameterValue, error) GetProvisionerDaemonByID(ctx context.Context, id uuid.UUID) (ProvisionerDaemon, error) GetProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, error) GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (ProvisionerJob, error) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUID) ([]ProvisionerJob, error) + GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error) GetProvisionerLogsByIDBetween(ctx context.Context, arg GetProvisionerLogsByIDBetweenParams) ([]ProvisionerJobLog, error) GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) @@ -53,6 +58,8 @@ type querier interface { GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error) GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg GetTemplateVersionByTemplateIDAndNameParams) (TemplateVersion, error) GetTemplateVersionsByTemplateID(ctx context.Context, arg GetTemplateVersionsByTemplateIDParams) ([]TemplateVersion, error) + GetTemplateVersionsCreatedAfter(ctx context.Context, createdAt time.Time) ([]TemplateVersion, error) + GetTemplates(ctx context.Context) ([]Template, error) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) @@ -63,23 +70,28 @@ type querier interface { GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) 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) 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) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (WorkspaceBuild, error) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) GetWorkspaceBuildByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDParams) ([]WorkspaceBuild, error) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (WorkspaceBuild, error) GetWorkspaceBuildByWorkspaceIDAndName(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndNameParams) (WorkspaceBuild, error) + GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error) + GetWorkspaceResourcesCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResource, error) + GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]Workspace, error) GetWorkspacesAutostart(ctx context.Context) ([]Workspace, error) - GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspacesWithFilterParams) ([]Workspace, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) + InsertDeploymentID(ctx context.Context, value string) error InsertFile(ctx context.Context, arg InsertFileParams) (File, error) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 8667a60ecfa74..b91a784a2fc84 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -60,6 +60,47 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro return i, err } +const getAPIKeysLastUsedAfter = `-- name: GetAPIKeysLastUsedAfter :many +SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, lifetime_seconds FROM api_keys WHERE last_used > $1 +` + +func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) { + rows, err := q.db.QueryContext(ctx, getAPIKeysLastUsedAfter, lastUsed) + if err != nil { + return nil, err + } + defer rows.Close() + var items []APIKey + for rows.Next() { + var i APIKey + if err := rows.Scan( + &i.ID, + &i.HashedSecret, + &i.UserID, + &i.LastUsed, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.LoginType, + &i.OAuthAccessToken, + &i.OAuthRefreshToken, + &i.OAuthIDToken, + &i.OAuthExpiry, + &i.LifetimeSeconds, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertAPIKey = `-- name: InsertAPIKey :one INSERT INTO api_keys ( @@ -845,6 +886,50 @@ func (q *sqlQuerier) GetParameterSchemasByJobID(ctx context.Context, jobID uuid. return items, nil } +const getParameterSchemasCreatedAfter = `-- name: GetParameterSchemasCreatedAfter :many +SELECT id, created_at, job_id, name, description, default_source_scheme, default_source_value, allow_override_source, default_destination_scheme, allow_override_destination, default_refresh, redisplay_value, validation_error, validation_condition, validation_type_system, validation_value_type FROM parameter_schemas WHERE created_at > $1 +` + +func (q *sqlQuerier) GetParameterSchemasCreatedAfter(ctx context.Context, createdAt time.Time) ([]ParameterSchema, error) { + rows, err := q.db.QueryContext(ctx, getParameterSchemasCreatedAfter, createdAt) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ParameterSchema + for rows.Next() { + var i ParameterSchema + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.JobID, + &i.Name, + &i.Description, + &i.DefaultSourceScheme, + &i.DefaultSourceValue, + &i.AllowOverrideSource, + &i.DefaultDestinationScheme, + &i.AllowOverrideDestination, + &i.DefaultRefresh, + &i.RedisplayValue, + &i.ValidationError, + &i.ValidationCondition, + &i.ValidationTypeSystem, + &i.ValidationValueType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertParameterSchema = `-- name: InsertParameterSchema :one INSERT INTO parameter_schemas ( @@ -1470,6 +1555,49 @@ func (q *sqlQuerier) GetProvisionerJobsByIDs(ctx context.Context, ids []uuid.UUI return items, nil } +const getProvisionerJobsCreatedAfter = `-- name: GetProvisionerJobsCreatedAfter :many +SELECT id, created_at, updated_at, started_at, canceled_at, completed_at, error, organization_id, initiator_id, provisioner, storage_method, storage_source, type, input, worker_id FROM provisioner_jobs WHERE created_at > $1 +` + +func (q *sqlQuerier) GetProvisionerJobsCreatedAfter(ctx context.Context, createdAt time.Time) ([]ProvisionerJob, error) { + rows, err := q.db.QueryContext(ctx, getProvisionerJobsCreatedAfter, createdAt) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProvisionerJob + for rows.Next() { + var i ProvisionerJob + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.StartedAt, + &i.CanceledAt, + &i.CompletedAt, + &i.Error, + &i.OrganizationID, + &i.InitiatorID, + &i.Provisioner, + &i.StorageMethod, + &i.StorageSource, + &i.Type, + &i.Input, + &i.WorkerID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertProvisionerJob = `-- name: InsertProvisionerJob :one INSERT INTO provisioner_jobs ( @@ -1601,6 +1729,26 @@ func (q *sqlQuerier) UpdateProvisionerJobWithCompleteByID(ctx context.Context, a return err } +const getDeploymentID = `-- name: GetDeploymentID :one +SELECT value FROM site_configs WHERE key = 'deployment_id' +` + +func (q *sqlQuerier) GetDeploymentID(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getDeploymentID) + var value string + err := row.Scan(&value) + return value, err +} + +const insertDeploymentID = `-- name: InsertDeploymentID :exec +INSERT INTO site_configs (key, value) VALUES ('deployment_id', $1) +` + +func (q *sqlQuerier) InsertDeploymentID(ctx context.Context, value string) error { + _, err := q.db.ExecContext(ctx, insertDeploymentID, value) + return err +} + const getTemplateByID = `-- name: GetTemplateByID :one SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by @@ -1671,6 +1819,46 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G return i, err } +const getTemplates = `-- name: GetTemplates :many +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by FROM templates +` + +func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { + rows, err := q.db.QueryContext(ctx, getTemplates) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Template + for rows.Next() { + var i Template + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OrganizationID, + &i.Deleted, + &i.Name, + &i.Provisioner, + &i.ActiveVersionID, + &i.Description, + &i.MaxTtl, + &i.MinAutostartInterval, + &i.CreatedBy, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by @@ -2043,6 +2231,42 @@ func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg Ge return items, nil } +const getTemplateVersionsCreatedAfter = `-- name: GetTemplateVersionsCreatedAfter :many +SELECT id, template_id, organization_id, created_at, updated_at, name, readme, job_id FROM template_versions WHERE created_at > $1 +` + +func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, createdAt time.Time) ([]TemplateVersion, error) { + rows, err := q.db.QueryContext(ctx, getTemplateVersionsCreatedAfter, createdAt) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TemplateVersion + for rows.Next() { + var i TemplateVersion + if err := rows.Scan( + &i.ID, + &i.TemplateID, + &i.OrganizationID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + &i.Readme, + &i.JobID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertTemplateVersion = `-- name: InsertTemplateVersion :one INSERT INTO template_versions ( @@ -2715,6 +2939,51 @@ func (q *sqlQuerier) GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids [] return items, nil } +const getWorkspaceAgentsCreatedAfter = `-- name: GetWorkspaceAgentsCreatedAfter :many +SELECT id, created_at, updated_at, name, first_connected_at, last_connected_at, disconnected_at, resource_id, auth_token, auth_instance_id, architecture, environment_variables, operating_system, startup_script, instance_metadata, resource_metadata, directory FROM workspace_agents WHERE created_at > $1 +` + +func (q *sqlQuerier) GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceAgentsCreatedAfter, createdAt) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAgent + for rows.Next() { + var i WorkspaceAgent + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Name, + &i.FirstConnectedAt, + &i.LastConnectedAt, + &i.DisconnectedAt, + &i.ResourceID, + &i.AuthToken, + &i.AuthInstanceID, + &i.Architecture, + &i.EnvironmentVariables, + &i.OperatingSystem, + &i.StartupScript, + &i.InstanceMetadata, + &i.ResourceMetadata, + &i.Directory, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertWorkspaceAgent = `-- name: InsertWorkspaceAgent :one INSERT INTO workspace_agents ( @@ -2919,6 +3188,42 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid. return items, nil } +const getWorkspaceAppsCreatedAfter = `-- name: GetWorkspaceAppsCreatedAfter :many +SELECT id, created_at, agent_id, name, icon, command, url, relative_path FROM workspace_apps WHERE created_at > $1 +` + +func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceApp, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceAppsCreatedAfter, createdAt) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceApp + for rows.Next() { + var i WorkspaceApp + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.AgentID, + &i.Name, + &i.Icon, + &i.Command, + &i.Url, + &i.RelativePath, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertWorkspaceApp = `-- name: InsertWorkspaceApp :one INSERT INTO workspace_apps ( @@ -3270,6 +3575,46 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndName(ctx context.Context, return i, err } +const getWorkspaceBuildsCreatedAfter = `-- name: GetWorkspaceBuildsCreatedAfter :many +SELECT id, created_at, updated_at, workspace_id, template_version_id, name, build_number, transition, initiator_id, provisioner_state, job_id, deadline FROM workspace_builds WHERE created_at > $1 +` + +func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceBuildsCreatedAfter, createdAt) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceBuild + for rows.Next() { + var i WorkspaceBuild + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.WorkspaceID, + &i.TemplateVersionID, + &i.Name, + &i.BuildNumber, + &i.Transition, + &i.InitiatorID, + &i.ProvisionerState, + &i.JobID, + &i.Deadline, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertWorkspaceBuild = `-- name: InsertWorkspaceBuild :one INSERT INTO workspace_builds ( @@ -3428,6 +3773,40 @@ func (q *sqlQuerier) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uui return items, nil } +const getWorkspaceResourcesCreatedAfter = `-- name: GetWorkspaceResourcesCreatedAfter :many +SELECT id, created_at, job_id, transition, type, name FROM workspace_resources WHERE created_at > $1 +` + +func (q *sqlQuerier) GetWorkspaceResourcesCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResource, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceResourcesCreatedAfter, createdAt) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceResource + for rows.Next() { + var i WorkspaceResource + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.JobID, + &i.Transition, + &i.Type, + &i.Name, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertWorkspaceResource = `-- name: InsertWorkspaceResource :one INSERT INTO workspace_resources (id, created_at, job_id, transition, type, name) @@ -3570,56 +3949,7 @@ func (q *sqlQuerier) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, i return items, nil } -const getWorkspacesAutostart = `-- name: GetWorkspacesAutostart :many -SELECT - id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl -FROM - workspaces -WHERE - deleted = false -AND -( - (autostart_schedule IS NOT NULL AND autostart_schedule <> '') - OR - (ttl IS NOT NULL AND ttl > 0) -) -` - -func (q *sqlQuerier) GetWorkspacesAutostart(ctx context.Context) ([]Workspace, error) { - rows, err := q.db.QueryContext(ctx, getWorkspacesAutostart) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Workspace - for rows.Next() { - var i Workspace - if err := rows.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.OwnerID, - &i.OrganizationID, - &i.TemplateID, - &i.Deleted, - &i.Name, - &i.AutostartSchedule, - &i.Ttl, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getWorkspacesWithFilter = `-- name: GetWorkspacesWithFilter :many +const getWorkspaces = `-- name: GetWorkspaces :many SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl FROM @@ -3661,7 +3991,7 @@ WHERE END ` -type GetWorkspacesWithFilterParams struct { +type GetWorkspacesParams struct { Deleted bool `db:"deleted" json:"deleted"` OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` OwnerUsername string `db:"owner_username" json:"owner_username"` @@ -3670,8 +4000,8 @@ type GetWorkspacesWithFilterParams struct { Name string `db:"name" json:"name"` } -func (q *sqlQuerier) GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspacesWithFilterParams) ([]Workspace, error) { - rows, err := q.db.QueryContext(ctx, getWorkspacesWithFilter, +func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]Workspace, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaces, arg.Deleted, arg.OwnerID, arg.OwnerUsername, @@ -3711,6 +4041,55 @@ func (q *sqlQuerier) GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspa return items, nil } +const getWorkspacesAutostart = `-- name: GetWorkspacesAutostart :many +SELECT + id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl +FROM + workspaces +WHERE + deleted = false +AND +( + (autostart_schedule IS NOT NULL AND autostart_schedule <> '') + OR + (ttl IS NOT NULL AND ttl > 0) +) +` + +func (q *sqlQuerier) GetWorkspacesAutostart(ctx context.Context) ([]Workspace, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesAutostart) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Workspace + for rows.Next() { + var i Workspace + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.OrganizationID, + &i.TemplateID, + &i.Deleted, + &i.Name, + &i.AutostartSchedule, + &i.Ttl, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertWorkspace = `-- name: InsertWorkspace :one INSERT INTO workspaces ( diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index 9e7cc8e252b7d..c24f779e1d08a 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -8,6 +8,9 @@ WHERE LIMIT 1; +-- name: GetAPIKeysLastUsedAfter :many +SELECT * FROM api_keys WHERE last_used > $1; + -- name: InsertAPIKey :one INSERT INTO api_keys ( diff --git a/coderd/database/queries/parameterschemas.sql b/coderd/database/queries/parameterschemas.sql index e813138a8a89f..194a97930b6ca 100644 --- a/coderd/database/queries/parameterschemas.sql +++ b/coderd/database/queries/parameterschemas.sql @@ -6,6 +6,9 @@ FROM WHERE job_id = $1; +-- name: GetParameterSchemasCreatedAfter :many +SELECT * FROM parameter_schemas WHERE created_at > $1; + -- name: InsertParameterSchema :one INSERT INTO parameter_schemas ( diff --git a/coderd/database/queries/provisionerjobs.sql b/coderd/database/queries/provisionerjobs.sql index 116299804c6b1..da4fa6d1824d0 100644 --- a/coderd/database/queries/provisionerjobs.sql +++ b/coderd/database/queries/provisionerjobs.sql @@ -46,6 +46,9 @@ FROM WHERE id = ANY(@ids :: uuid [ ]); +-- name: GetProvisionerJobsCreatedAfter :many +SELECT * FROM provisioner_jobs WHERE created_at > $1; + -- name: InsertProvisionerJob :one INSERT INTO provisioner_jobs ( diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql new file mode 100644 index 0000000000000..9d3936e23886d --- /dev/null +++ b/coderd/database/queries/siteconfig.sql @@ -0,0 +1,5 @@ +-- name: InsertDeploymentID :exec +INSERT INTO site_configs (key, value) VALUES ('deployment_id', $1); + +-- name: GetDeploymentID :one +SELECT value FROM site_configs WHERE key = 'deployment_id'; diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index f0c4f802ac388..31b3599a13ec0 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -48,6 +48,9 @@ WHERE LIMIT 1; +-- name: GetTemplates :many +SELECT * FROM templates; + -- name: InsertTemplate :one INSERT INTO templates ( diff --git a/coderd/database/queries/templateversions.sql b/coderd/database/queries/templateversions.sql index 148c8856deeb0..231b3512553df 100644 --- a/coderd/database/queries/templateversions.sql +++ b/coderd/database/queries/templateversions.sql @@ -40,6 +40,9 @@ FROM WHERE job_id = $1; +-- name: GetTemplateVersionsCreatedAfter :many +SELECT * FROM template_versions WHERE created_at > $1; + -- name: GetTemplateVersionByTemplateIDAndName :one SELECT * diff --git a/coderd/database/queries/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index de15fe451687b..3d6269a700d62 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -34,6 +34,9 @@ FROM WHERE resource_id = ANY(@ids :: uuid [ ]); +-- name: GetWorkspaceAgentsCreatedAfter :many +SELECT * FROM workspace_agents WHERE created_at > $1; + -- name: InsertWorkspaceAgent :one INSERT INTO workspace_agents ( diff --git a/coderd/database/queries/workspaceapps.sql b/coderd/database/queries/workspaceapps.sql index 61898d1caf56c..e4be54176d962 100644 --- a/coderd/database/queries/workspaceapps.sql +++ b/coderd/database/queries/workspaceapps.sql @@ -7,6 +7,9 @@ SELECT * FROM workspace_apps WHERE agent_id = ANY(@ids :: uuid [ ]); -- name: GetWorkspaceAppByAgentIDAndName :one SELECT * FROM workspace_apps WHERE agent_id = $1 AND name = $2; +-- name: GetWorkspaceAppsCreatedAfter :many +SELECT * FROM workspace_apps WHERE created_at > $1; + -- name: InsertWorkspaceApp :one INSERT INTO workspace_apps ( diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index 4bace271cb125..df2ce44c94f1e 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -18,6 +18,9 @@ WHERE LIMIT 1; +-- name: GetWorkspaceBuildsCreatedAfter :many +SELECT * FROM workspace_builds WHERE created_at > $1; + -- name: GetWorkspaceBuildByWorkspaceIDAndName :one SELECT * diff --git a/coderd/database/queries/workspaceresources.sql b/coderd/database/queries/workspaceresources.sql index c120cf41a8d57..3cd93d6e6e6a6 100644 --- a/coderd/database/queries/workspaceresources.sql +++ b/coderd/database/queries/workspaceresources.sql @@ -14,6 +14,9 @@ FROM WHERE job_id = $1; +-- name: GetWorkspaceResourcesCreatedAfter :many +SELECT * FROM workspace_resources WHERE created_at > $1; + -- name: InsertWorkspaceResource :one INSERT INTO workspace_resources (id, created_at, job_id, transition, type, name) diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index dc68186fdc794..5d939a8477e33 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -8,7 +8,7 @@ WHERE LIMIT 1; --- name: GetWorkspacesWithFilter :many +-- name: GetWorkspaces :many SELECT * FROM diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index dd0c3ea3be906..272a1d0f604c7 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -26,6 +26,7 @@ import ( "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/parameter" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/provisionerd/proto" "github.com/coder/coder/provisionersdk" sdkproto "github.com/coder/coder/provisionersdk/proto" @@ -80,6 +81,7 @@ func (api *API) ListenProvisionerDaemon(ctx context.Context) (client proto.DRPCP Database: api.Database, Pubsub: api.Pubsub, Provisioners: daemon.Provisioners, + Telemetry: api.Telemetry, Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), }) if err != nil { @@ -127,6 +129,7 @@ type provisionerdServer struct { Provisioners []database.ProvisionerType Database database.Store Pubsub database.Pubsub + Telemetry telemetry.Reporter } // AcquireJob queries the database to lock a job. @@ -490,21 +493,28 @@ func (server *provisionerdServer) FailJob(ctx context.Context, failJob *proto.Fa if job.CompletedAt.Valid { return nil, xerrors.Errorf("job already completed") } + job.CompletedAt = sql.NullTime{ + Time: database.Now(), + Valid: true, + } + job.Error = sql.NullString{ + String: failJob.Error, + Valid: failJob.Error != "", + } + err = server.Database.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ - ID: jobID, - CompletedAt: sql.NullTime{ - Time: database.Now(), - Valid: true, - }, - UpdatedAt: database.Now(), - Error: sql.NullString{ - String: failJob.Error, - Valid: failJob.Error != "", - }, + ID: jobID, + CompletedAt: job.CompletedAt, + UpdatedAt: database.Now(), + Error: job.Error, }) if err != nil { return nil, xerrors.Errorf("update provisioner job: %w", err) } + server.Telemetry.Report(&telemetry.Snapshot{ + ProvisionerJobs: []telemetry.ProvisionerJob{telemetry.ConvertProvisionerJob(job)}, + }) + switch jobType := failJob.Type.(type) { case *proto.FailedJob_WorkspaceBuild_: if jobType.WorkspaceBuild.State == nil { @@ -543,6 +553,10 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr return nil, xerrors.Errorf("you don't have permission to update this job") } + telemetrySnapshot := &telemetry.Snapshot{} + // Items are added to this snapshot as they complete! + defer server.Telemetry.Report(telemetrySnapshot) + switch jobType := completed.Type.(type) { case *proto.CompletedJob_TemplateImport_: for transition, resources := range map[database.WorkspaceTransition][]*sdkproto.Resource{ @@ -556,7 +570,7 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr slog.F("resource_type", resource.Type), slog.F("transition", transition)) - err = insertWorkspaceResource(ctx, server.Database, jobID, transition, resource) + err = insertWorkspaceResource(ctx, server.Database, jobID, transition, resource, telemetrySnapshot) if err != nil { return nil, xerrors.Errorf("insert resource: %w", err) } @@ -625,7 +639,7 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr } // This could be a bulk insert to improve performance. for _, protoResource := range jobType.WorkspaceBuild.Resources { - err = insertWorkspaceResource(ctx, db, job.ID, workspaceBuild.Transition, protoResource) + err = insertWorkspaceResource(ctx, db, job.ID, workspaceBuild.Transition, protoResource, telemetrySnapshot) if err != nil { return xerrors.Errorf("insert provisioner job: %w", err) } @@ -656,7 +670,7 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr slog.F("resource_name", resource.Name), slog.F("resource_type", resource.Type)) - err = insertWorkspaceResource(ctx, server.Database, jobID, database.WorkspaceTransitionStart, resource) + err = insertWorkspaceResource(ctx, server.Database, jobID, database.WorkspaceTransitionStart, resource, telemetrySnapshot) if err != nil { return nil, xerrors.Errorf("insert resource: %w", err) } @@ -686,7 +700,7 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr return &proto.Empty{}, nil } -func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.UUID, transition database.WorkspaceTransition, protoResource *sdkproto.Resource) error { +func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.UUID, transition database.WorkspaceTransition, protoResource *sdkproto.Resource, snapshot *telemetry.Snapshot) error { resource, err := db.InsertWorkspaceResource(ctx, database.InsertWorkspaceResourceParams{ ID: uuid.New(), CreatedAt: database.Now(), @@ -698,6 +712,8 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. if err != nil { return xerrors.Errorf("insert provisioner job resource %q: %w", protoResource.Name, err) } + snapshot.WorkspaceResources = append(snapshot.WorkspaceResources, telemetry.ConvertWorkspaceResource(resource)) + for _, agent := range protoResource.Agents { var instanceID sql.NullString if agent.GetInstanceId() != "" { @@ -745,9 +761,10 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. if err != nil { return xerrors.Errorf("insert agent: %w", err) } + snapshot.WorkspaceAgents = append(snapshot.WorkspaceAgents, telemetry.ConvertWorkspaceAgent(dbAgent)) for _, app := range agent.Apps { - _, err := db.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{ + dbApp, err := db.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{ ID: uuid.New(), CreatedAt: database.Now(), AgentID: dbAgent.ID, @@ -766,6 +783,7 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. if err != nil { return xerrors.Errorf("insert app: %w", err) } + snapshot.WorkspaceApps = append(snapshot.WorkspaceApps, telemetry.ConvertWorkspaceApp(dbApp)) } } return nil diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go new file mode 100644 index 0000000000000..0506eac387732 --- /dev/null +++ b/coderd/telemetry/telemetry.go @@ -0,0 +1,721 @@ +package telemetry + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "runtime" + "strings" + "sync" + "time" + + "github.com/elastic/go-sysinfo" + "github.com/google/uuid" + "golang.org/x/sync/errgroup" + "golang.org/x/xerrors" + + "cdr.dev/slog" + + "github.com/coder/coder/buildinfo" + "github.com/coder/coder/coderd/database" +) + +const ( + // VersionHeader is sent in every telemetry request to + // report the semantic version of Coder. + VersionHeader = "X-Coder-Version" +) + +type Options struct { + Database database.Store + Logger slog.Logger + // URL is an endpoint to direct telemetry towards! + URL *url.URL + + BuiltinPostgres bool + DeploymentID string + GitHubOAuth bool + Prometheus bool + STUN bool + SnapshotFrequency time.Duration + Tunnel bool +} + +// New constructs a reporter for telemetry data. +// Duplicate data will be sent, it's on the server-side to index by UUID. +// Data is anonymized prior to being sent! +func New(options Options) (Reporter, error) { + if options.SnapshotFrequency == 0 { + // Report once every 30mins by default! + options.SnapshotFrequency = 30 * time.Minute + } + snapshotURL, err := options.URL.Parse("/snapshot") + if err != nil { + return nil, xerrors.Errorf("parse snapshot url: %w", err) + } + deploymentURL, err := options.URL.Parse("/deployment") + if err != nil { + return nil, xerrors.Errorf("parse deployment url: %w", err) + } + + ctx, cancelFunc := context.WithCancel(context.Background()) + reporter := &remoteReporter{ + ctx: ctx, + closed: make(chan struct{}), + closeFunc: cancelFunc, + options: options, + deploymentURL: deploymentURL, + snapshotURL: snapshotURL, + startedAt: database.Now(), + } + go reporter.runSnapshotter() + return reporter, nil +} + +// NewNoop creates a new telemetry reporter that entirely discards all requests. +func NewNoop() Reporter { + return &noopReporter{} +} + +// Reporter sends data to the telemetry server. +type Reporter interface { + // Report sends a snapshot to the telemetry server. + // The contents of the snapshot can be a partial representation of the + // database. For example, if a new user is added, a snapshot can + // contain just that user entry. + Report(snapshot *Snapshot) + Close() +} + +type remoteReporter struct { + ctx context.Context + closed chan struct{} + closeMutex sync.Mutex + closeFunc context.CancelFunc + + options Options + deploymentURL, + snapshotURL *url.URL + startedAt time.Time + shutdownAt *time.Time +} + +func (r *remoteReporter) Report(snapshot *Snapshot) { + go r.reportSync(snapshot) +} + +func (r *remoteReporter) reportSync(snapshot *Snapshot) { + snapshot.DeploymentID = r.options.DeploymentID + data, err := json.Marshal(snapshot) + if err != nil { + r.options.Logger.Error(r.ctx, "marshal snapshot: %w", slog.Error(err)) + return + } + req, err := http.NewRequestWithContext(r.ctx, "POST", r.snapshotURL.String(), bytes.NewReader(data)) + if err != nil { + r.options.Logger.Error(r.ctx, "create request", slog.Error(err)) + return + } + req.Header.Set(VersionHeader, buildinfo.Version()) + resp, err := http.DefaultClient.Do(req) + if err != nil { + // If the request fails it's not necessarily an error. + // In an airgapped environment, it's fine if this fails! + r.options.Logger.Debug(r.ctx, "submit", slog.Error(err)) + return + } + if resp.StatusCode != http.StatusAccepted { + r.options.Logger.Debug(r.ctx, "bad response from telemetry server", slog.F("status", resp.StatusCode)) + return + } + r.options.Logger.Debug(r.ctx, "submitted snapshot") +} + +func (r *remoteReporter) Close() { + r.closeMutex.Lock() + defer r.closeMutex.Unlock() + if r.isClosed() { + return + } + close(r.closed) + now := database.Now() + r.shutdownAt = &now + // Report a final collection of telemetry prior to close! + // This could indicate final actions a user has taken, and + // the time the deployment was shutdown. + r.reportWithDeployment() + r.closeFunc() +} + +func (r *remoteReporter) isClosed() bool { + select { + case <-r.closed: + return true + default: + return false + } +} + +func (r *remoteReporter) runSnapshotter() { + first := true + ticker := time.NewTicker(r.options.SnapshotFrequency) + defer ticker.Stop() + for { + if !first { + select { + case <-r.closed: + return + case <-ticker.C: + } + // Skip the ticker on the first run to report instantly! + } + first = false + r.closeMutex.Lock() + if r.isClosed() { + r.closeMutex.Unlock() + return + } + r.reportWithDeployment() + r.closeMutex.Unlock() + } +} + +func (r *remoteReporter) reportWithDeployment() { + // Submit deployment information before creating a snapshot! + // This is separated from the snapshot API call to reduce + // duplicate data from being inserted. Snapshot may be called + // numerous times simaltanously if there is lots of activity! + err := r.deployment() + if err != nil { + r.options.Logger.Debug(r.ctx, "update deployment", slog.Error(err)) + return + } + snapshot, err := r.createSnapshot() + if errors.Is(err, context.Canceled) { + return + } + if err != nil { + r.options.Logger.Error(r.ctx, "create snapshot", slog.Error(err)) + return + } + r.reportSync(snapshot) +} + +// deployment collects host information and reports it to the telemetry server. +func (r *remoteReporter) deployment() error { + sysInfoHost, err := sysinfo.Host() + if err != nil { + return xerrors.Errorf("get host info: %w", err) + } + mem, err := sysInfoHost.Memory() + if err != nil { + return xerrors.Errorf("get memory info: %w", err) + } + sysInfo := sysInfoHost.Info() + + containerized := false + if sysInfo.Containerized != nil { + containerized = *sysInfo.Containerized + } + data, err := json.Marshal(&Deployment{ + ID: r.options.DeploymentID, + Architecture: sysInfo.Architecture, + BuiltinPostgres: r.options.BuiltinPostgres, + Containerized: containerized, + GitHubOAuth: r.options.GitHubOAuth, + Prometheus: r.options.Prometheus, + STUN: r.options.STUN, + Tunnel: r.options.Tunnel, + OSType: sysInfo.OS.Type, + OSFamily: sysInfo.OS.Family, + OSPlatform: sysInfo.OS.Platform, + OSName: sysInfo.OS.Name, + OSVersion: sysInfo.OS.Version, + CPUCores: runtime.NumCPU(), + MemoryTotal: mem.Total, + MachineID: sysInfo.UniqueID, + StartedAt: r.startedAt, + ShutdownAt: r.shutdownAt, + }) + if err != nil { + return xerrors.Errorf("marshal deployment: %w", err) + } + req, err := http.NewRequestWithContext(r.ctx, "POST", r.deploymentURL.String(), bytes.NewReader(data)) + if err != nil { + return xerrors.Errorf("create deployment request: %w", err) + } + req.Header.Set(VersionHeader, buildinfo.Version()) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return xerrors.Errorf("perform request: %w", err) + } + if resp.StatusCode != http.StatusAccepted { + return xerrors.Errorf("update deployment: %w", err) + } + r.options.Logger.Debug(r.ctx, "submitted deployment info") + return nil +} + +// createSnapshot collects a full snapshot from the database. +func (r *remoteReporter) createSnapshot() (*Snapshot, error) { + var ( + ctx = r.ctx + // For resources that grow in size very quickly (like workspace builds), + // we only report events that occurred within the past hour. + createdAfter = database.Now().Add(-1 * time.Hour) + eg errgroup.Group + snapshot = &Snapshot{ + DeploymentID: r.options.DeploymentID, + } + ) + + eg.Go(func() error { + apiKeys, err := r.options.Database.GetAPIKeysLastUsedAfter(ctx, createdAfter) + if err != nil { + return xerrors.Errorf("get api keys last used: %w", err) + } + snapshot.APIKeys = make([]APIKey, 0, len(apiKeys)) + for _, apiKey := range apiKeys { + snapshot.APIKeys = append(snapshot.APIKeys, ConvertAPIKey(apiKey)) + } + return nil + }) + eg.Go(func() error { + schemas, err := r.options.Database.GetParameterSchemasCreatedAfter(ctx, createdAfter) + if err != nil { + return xerrors.Errorf("get parameter schemas: %w", err) + } + snapshot.ParameterSchemas = make([]ParameterSchema, 0, len(schemas)) + for _, schema := range schemas { + snapshot.ParameterSchemas = append(snapshot.ParameterSchemas, ParameterSchema{ + ID: schema.ID, + JobID: schema.JobID, + Name: schema.Name, + ValidationCondition: schema.ValidationCondition, + }) + } + return nil + }) + eg.Go(func() error { + jobs, err := r.options.Database.GetProvisionerJobsCreatedAfter(ctx, createdAfter) + if err != nil { + return xerrors.Errorf("get provisioner jobs: %w", err) + } + snapshot.ProvisionerJobs = make([]ProvisionerJob, 0, len(jobs)) + for _, job := range jobs { + snapshot.ProvisionerJobs = append(snapshot.ProvisionerJobs, ConvertProvisionerJob(job)) + } + return nil + }) + eg.Go(func() error { + templates, err := r.options.Database.GetTemplates(ctx) + if err != nil { + return xerrors.Errorf("get templates: %w", err) + } + snapshot.Templates = make([]Template, 0, len(templates)) + for _, dbTemplate := range templates { + snapshot.Templates = append(snapshot.Templates, ConvertTemplate(dbTemplate)) + } + return nil + }) + eg.Go(func() error { + templateVersions, err := r.options.Database.GetTemplateVersionsCreatedAfter(ctx, createdAfter) + if err != nil { + return xerrors.Errorf("get template versions: %w", err) + } + snapshot.TemplateVersions = make([]TemplateVersion, 0, len(templateVersions)) + for _, version := range templateVersions { + snapshot.TemplateVersions = append(snapshot.TemplateVersions, ConvertTemplateVersion(version)) + } + return nil + }) + eg.Go(func() error { + users, err := r.options.Database.GetUsers(ctx, database.GetUsersParams{}) + if err != nil { + return xerrors.Errorf("get users: %w", err) + } + var firstUser database.User + for _, dbUser := range users { + if dbUser.Status != database.UserStatusActive { + continue + } + if firstUser.CreatedAt.IsZero() { + firstUser = dbUser + } + if dbUser.CreatedAt.After(firstUser.CreatedAt) { + firstUser = dbUser + } + } + snapshot.Users = make([]User, 0, len(users)) + for _, dbUser := range users { + user := ConvertUser(dbUser) + // If it's the first user, we'll send the email! + if firstUser.ID == dbUser.ID { + email := dbUser.Email + user.Email = &email + } + snapshot.Users = append(snapshot.Users, user) + } + return nil + }) + eg.Go(func() error { + workspaces, err := r.options.Database.GetWorkspaces(ctx, database.GetWorkspacesParams{}) + if err != nil { + return xerrors.Errorf("get workspaces: %w", err) + } + snapshot.Workspaces = make([]Workspace, 0, len(workspaces)) + for _, dbWorkspace := range workspaces { + snapshot.Workspaces = append(snapshot.Workspaces, ConvertWorkspace(dbWorkspace)) + } + return nil + }) + eg.Go(func() error { + workspaceApps, err := r.options.Database.GetWorkspaceAppsCreatedAfter(ctx, createdAfter) + if err != nil { + return xerrors.Errorf("get workspace apps: %w", err) + } + snapshot.WorkspaceApps = make([]WorkspaceApp, 0, len(workspaceApps)) + for _, app := range workspaceApps { + snapshot.WorkspaceApps = append(snapshot.WorkspaceApps, ConvertWorkspaceApp(app)) + } + return nil + }) + eg.Go(func() error { + workspaceAgents, err := r.options.Database.GetWorkspaceAgentsCreatedAfter(ctx, createdAfter) + if err != nil { + return xerrors.Errorf("get workspace agents: %w", err) + } + snapshot.WorkspaceAgents = make([]WorkspaceAgent, 0, len(workspaceAgents)) + for _, agent := range workspaceAgents { + snapshot.WorkspaceAgents = append(snapshot.WorkspaceAgents, ConvertWorkspaceAgent(agent)) + } + return nil + }) + eg.Go(func() error { + workspaceBuilds, err := r.options.Database.GetWorkspaceBuildsCreatedAfter(ctx, createdAfter) + if err != nil { + return xerrors.Errorf("get workspace builds: %w", err) + } + snapshot.WorkspaceBuilds = make([]WorkspaceBuild, 0, len(workspaceBuilds)) + for _, build := range workspaceBuilds { + snapshot.WorkspaceBuilds = append(snapshot.WorkspaceBuilds, ConvertWorkspaceBuild(build)) + } + return nil + }) + eg.Go(func() error { + workspaceResources, err := r.options.Database.GetWorkspaceResourcesCreatedAfter(ctx, createdAfter) + if err != nil { + return xerrors.Errorf("get workspace resources: %w", err) + } + snapshot.WorkspaceResources = make([]WorkspaceResource, 0, len(workspaceResources)) + for _, resource := range workspaceResources { + snapshot.WorkspaceResources = append(snapshot.WorkspaceResources, ConvertWorkspaceResource(resource)) + } + return nil + }) + + err := eg.Wait() + if err != nil { + return nil, err + } + return snapshot, nil +} + +// ConvertAPIKey anonymizes an API key. +func ConvertAPIKey(apiKey database.APIKey) APIKey { + return APIKey{ + ID: apiKey.ID, + UserID: apiKey.UserID, + CreatedAt: apiKey.CreatedAt, + LastUsed: apiKey.LastUsed, + LoginType: apiKey.LoginType, + } +} + +// ConvertWorkspace anonymizes a workspace. +func ConvertWorkspace(workspace database.Workspace) Workspace { + return Workspace{ + ID: workspace.ID, + OrganizationID: workspace.OrganizationID, + OwnerID: workspace.OwnerID, + TemplateID: workspace.TemplateID, + CreatedAt: workspace.CreatedAt, + Deleted: workspace.Deleted, + Name: workspace.Name, + AutostartSchedule: workspace.AutostartSchedule.String, + } +} + +// ConvertWorkspaceBuild anonymizes a workspace build. +func ConvertWorkspaceBuild(build database.WorkspaceBuild) WorkspaceBuild { + return WorkspaceBuild{ + ID: build.ID, + CreatedAt: build.CreatedAt, + WorkspaceID: build.WorkspaceID, + JobID: build.JobID, + TemplateVersionID: build.TemplateVersionID, + BuildNumber: uint32(build.BuildNumber), + } +} + +// ConvertProvisionerJob anonymizes a provisioner job. +func ConvertProvisionerJob(job database.ProvisionerJob) ProvisionerJob { + snapJob := ProvisionerJob{ + ID: job.ID, + OrganizationID: job.OrganizationID, + InitiatorID: job.InitiatorID, + CreatedAt: job.CreatedAt, + UpdatedAt: job.UpdatedAt, + Error: job.Error.String, + Type: job.Type, + } + if job.StartedAt.Valid { + snapJob.StartedAt = &job.StartedAt.Time + } + if job.CanceledAt.Valid { + snapJob.CanceledAt = &job.CanceledAt.Time + } + if job.CompletedAt.Valid { + snapJob.CompletedAt = &job.CompletedAt.Time + } + return snapJob +} + +// ConvertWorkspaceAgent anonymizes a workspace agent. +func ConvertWorkspaceAgent(agent database.WorkspaceAgent) WorkspaceAgent { + return WorkspaceAgent{ + ID: agent.ID, + CreatedAt: agent.CreatedAt, + ResourceID: agent.ResourceID, + InstanceAuth: agent.AuthInstanceID.Valid, + Architecture: agent.Architecture, + OperatingSystem: agent.OperatingSystem, + EnvironmentVariables: agent.EnvironmentVariables.Valid, + StartupScript: agent.StartupScript.Valid, + Directory: agent.Directory != "", + } +} + +// ConvertWorkspaceApp anonymizes a workspace app. +func ConvertWorkspaceApp(app database.WorkspaceApp) WorkspaceApp { + return WorkspaceApp{ + ID: app.ID, + CreatedAt: app.CreatedAt, + AgentID: app.AgentID, + Icon: app.Icon, + RelativePath: app.RelativePath, + } +} + +// ConvertWorkspaceResource anonymizes a workspace resource. +func ConvertWorkspaceResource(resource database.WorkspaceResource) WorkspaceResource { + return WorkspaceResource{ + ID: resource.ID, + JobID: resource.JobID, + Transition: resource.Transition, + Type: resource.Type, + } +} + +// ConvertUser anonymizes a user. +func ConvertUser(dbUser database.User) User { + emailHashed := "" + atSymbol := strings.LastIndex(dbUser.Email, "@") + if atSymbol >= 0 { + // We hash the beginning of the user to allow for indexing users + // by email between deployments. + hash := sha256.Sum256([]byte(dbUser.Email[:atSymbol])) + emailHashed = fmt.Sprintf("%x%s", hash[:], dbUser.Email[atSymbol:]) + } + return User{ + ID: dbUser.ID, + EmailHashed: emailHashed, + RBACRoles: dbUser.RBACRoles, + CreatedAt: dbUser.CreatedAt, + } +} + +// ConvertTemplate anonymizes a template. +func ConvertTemplate(dbTemplate database.Template) Template { + return Template{ + ID: dbTemplate.ID, + CreatedBy: dbTemplate.CreatedBy, + CreatedAt: dbTemplate.CreatedAt, + UpdatedAt: dbTemplate.UpdatedAt, + OrganizationID: dbTemplate.OrganizationID, + Deleted: dbTemplate.Deleted, + ActiveVersionID: dbTemplate.ActiveVersionID, + Name: dbTemplate.Name, + Description: dbTemplate.Description != "", + } +} + +// ConvertTemplateVersion anonymizes a template version. +func ConvertTemplateVersion(version database.TemplateVersion) TemplateVersion { + snapVersion := TemplateVersion{ + ID: version.ID, + CreatedAt: version.CreatedAt, + OrganizationID: version.OrganizationID, + JobID: version.JobID, + } + if version.TemplateID.Valid { + snapVersion.TemplateID = &version.TemplateID.UUID + } + return snapVersion +} + +// 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. +type Snapshot struct { + DeploymentID string `json:"deployment_id"` + + APIKeys []APIKey `json:"api_keys"` + ParameterSchemas []ParameterSchema `json:"parameter_schemas"` + ProvisionerJobs []ProvisionerJob `json:"provisioner_jobs"` + Templates []Template `json:"templates"` + TemplateVersions []TemplateVersion `json:"template_versions"` + Users []User `json:"users"` + Workspaces []Workspace `json:"workspaces"` + WorkspaceApps []WorkspaceApp `json:"workspace_apps"` + WorkspaceAgents []WorkspaceAgent `json:"workspace_agents"` + WorkspaceBuilds []WorkspaceBuild `json:"workspace_build"` + WorkspaceResources []WorkspaceResource `json:"workspace_resources"` +} + +// Deployment contains information about the host running Coder. +type Deployment struct { + ID string `json:"id"` + Architecture string `json:"architecture"` + BuiltinPostgres bool `json:"builtin_postgres"` + Containerized bool `json:"containerized"` + Tunnel bool `json:"tunnel"` + GitHubOAuth bool `json:"github_oauth"` + Prometheus bool `json:"prometheus"` + STUN bool `json:"stun"` + OSType string `json:"os_type"` + OSFamily string `json:"os_family"` + OSPlatform string `json:"os_platform"` + OSName string `json:"os_name"` + OSVersion string `json:"os_version"` + CPUCores int `json:"cpu_cores"` + MemoryTotal uint64 `json:"memory_total"` + MachineID string `json:"machine_id"` + StartedAt time.Time `json:"started_at"` + ShutdownAt *time.Time `json:"shutdown_at"` +} + +type APIKey struct { + ID string `json:"id"` + UserID uuid.UUID `json:"user_id"` + CreatedAt time.Time `json:"created_at"` + LastUsed time.Time `json:"last_used"` + LoginType database.LoginType `json:"login_type"` +} + +type User struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + // Email is only filled in for the first/admin user! + Email *string `json:"email"` + EmailHashed string `json:"email_hashed"` + RBACRoles []string `json:"rbac_roles"` + Status database.UserStatus `json:"status"` +} + +type WorkspaceResource struct { + ID uuid.UUID `json:"id"` + JobID uuid.UUID `json:"job_id"` + Transition database.WorkspaceTransition `json:"transition"` + Type string `json:"type"` +} + +type WorkspaceAgent struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + ResourceID uuid.UUID `json:"resource_id"` + InstanceAuth bool `json:"instance_auth"` + Architecture string `json:"architecture"` + OperatingSystem string `json:"operating_system"` + EnvironmentVariables bool `json:"environment_variables"` + StartupScript bool `json:"startup_script"` + Directory bool `json:"directory"` +} + +type WorkspaceApp struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + AgentID uuid.UUID `json:"agent_id"` + Icon string `json:"icon"` + RelativePath bool `json:"relative_path"` +} + +type WorkspaceBuild struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + WorkspaceID uuid.UUID `json:"workspace_id"` + TemplateVersionID uuid.UUID `json:"template_version_id"` + JobID uuid.UUID `json:"job_id"` + BuildNumber uint32 `json:"build_number"` +} + +type Workspace struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OwnerID uuid.UUID `json:"owner_id"` + TemplateID uuid.UUID `json:"template_id"` + CreatedAt time.Time `json:"created_at"` + Deleted bool `json:"deleted"` + Name string `json:"name"` + AutostartSchedule string `json:"autostart_schedule"` +} + +type Template struct { + ID uuid.UUID `json:"id"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + OrganizationID uuid.UUID `json:"organization_id"` + Deleted bool `json:"deleted"` + ActiveVersionID uuid.UUID `json:"active_version_id"` + Name string `json:"name"` + Description bool `json:"description"` +} + +type TemplateVersion struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + TemplateID *uuid.UUID `json:"template_id,omitempty"` + OrganizationID uuid.UUID `json:"organization_id"` + JobID uuid.UUID `json:"job_id"` +} + +type ProvisionerJob struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + InitiatorID uuid.UUID `json:"initiator_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CanceledAt *time.Time `json:"canceled_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Error string `json:"error"` + Type database.ProvisionerJobType `json:"type"` +} + +type ParameterSchema struct { + ID uuid.UUID `json:"id"` + JobID uuid.UUID `json:"job_id"` + Name string `json:"name"` + ValidationCondition string `json:"validation_condition"` +} + +type noopReporter struct{} + +func (*noopReporter) Report(_ *Snapshot) {} +func (*noopReporter) Close() {} diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go new file mode 100644 index 0000000000000..4e78b19ec8c54 --- /dev/null +++ b/coderd/telemetry/telemetry_test.go @@ -0,0 +1,148 @@ +package telemetry_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-chi/chi" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/buildinfo" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/telemetry" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestTelemetry(t *testing.T) { + t.Parallel() + t.Run("Snapshot", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + ctx := context.Background() + _, err := db.InsertAPIKey(ctx, database.InsertAPIKeyParams{ + ID: uuid.NewString(), + LastUsed: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertParameterSchema(ctx, database.InsertParameterSchemaParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertTemplate(ctx, database.InsertTemplateParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertUser(ctx, database.InsertUserParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertWorkspace(ctx, database.InsertWorkspaceParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + }) + require.NoError(t, err) + _, err = db.InsertWorkspaceResource(ctx, database.InsertWorkspaceResourceParams{ + ID: uuid.New(), + CreatedAt: database.Now(), + }) + require.NoError(t, err) + snapshot := collectSnapshot(t, db) + require.Len(t, snapshot.ParameterSchemas, 1) + require.Len(t, snapshot.ProvisionerJobs, 1) + require.Len(t, snapshot.Templates, 1) + require.Len(t, snapshot.TemplateVersions, 1) + require.Len(t, snapshot.Users, 1) + 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) + }) + t.Run("HashedEmail", func(t *testing.T) { + t.Parallel() + db := databasefake.New() + _, err := db.InsertUser(context.Background(), database.InsertUserParams{ + ID: uuid.New(), + Email: "kyle@coder.com", + CreatedAt: database.Now(), + }) + require.NoError(t, err) + snapshot := collectSnapshot(t, db) + require.Len(t, snapshot.Users, 1) + require.Equal(t, snapshot.Users[0].EmailHashed, "bb44bf07cf9a2db0554bba63a03d822c927deae77df101874496df5a6a3e896d@coder.com") + }) +} + +func collectSnapshot(t *testing.T, db database.Store) *telemetry.Snapshot { + t.Helper() + deployment := make(chan struct{}, 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) + deployment <- struct{}{} + }) + 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) + reporter, err := telemetry.New(telemetry.Options{ + Database: db, + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + URL: serverURL, + DeploymentID: uuid.NewString(), + }) + require.NoError(t, err) + t.Cleanup(reporter.Close) + <-deployment + return <-snapshot +} diff --git a/coderd/templates.go b/coderd/templates.go index 33fdac588d910..60ab03ef938d3 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -16,6 +16,7 @@ import ( "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" ) @@ -75,7 +76,7 @@ func (api *API) deleteTemplate(rw http.ResponseWriter, r *http.Request) { return } - workspaces, err := api.Database.GetWorkspacesWithFilter(r.Context(), database.GetWorkspacesWithFilterParams{ + workspaces, err := api.Database.GetWorkspaces(r.Context(), database.GetWorkspacesParams{ TemplateIds: []uuid.UUID{template.ID}, }) if errors.Is(err, sql.ErrNoRows) { @@ -180,10 +181,11 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque minAutostartInterval = time.Duration(*createTemplate.MinAutostartIntervalMillis) * time.Millisecond } + var dbTemplate database.Template var template codersdk.Template err = api.Database.InTx(func(db database.Store) error { now := database.Now() - dbTemplate, err := db.InsertTemplate(r.Context(), database.InsertTemplateParams{ + dbTemplate, err = db.InsertTemplate(r.Context(), database.InsertTemplateParams{ ID: uuid.New(), CreatedAt: now, UpdatedAt: now, @@ -244,6 +246,11 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque return } + api.Telemetry.Report(&telemetry.Snapshot{ + Templates: []telemetry.Template{telemetry.ConvertTemplate(dbTemplate)}, + TemplateVersions: []telemetry.TemplateVersion{telemetry.ConvertTemplateVersion(templateVersion)}, + }) + httpapi.Write(rw, http.StatusCreated, template) } diff --git a/coderd/users.go b/coderd/users.go index 781cc213d1797..aa7d5e549ead2 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -20,6 +20,7 @@ import ( "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/userpassword" "github.com/coder/coder/codersdk" "github.com/coder/coder/cryptorand" @@ -86,6 +87,13 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { return } + telemetryUser := telemetry.ConvertUser(user) + // Send the initial users email address! + telemetryUser.Email = &user.Email + api.Telemetry.Report(&telemetry.Snapshot{ + Users: []telemetry.User{telemetryUser}, + }) + // TODO: @emyrk this currently happens outside the database tx used to create // the user. Maybe I add this ability to grant roles in the createUser api // and add some rbac bypass when calling api functions this way?? @@ -252,6 +260,11 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { return } + // Report when users are added! + api.Telemetry.Report(&telemetry.Snapshot{ + Users: []telemetry.User{telemetry.ConvertUser(user)}, + }) + httpapi.Write(rw, http.StatusCreated, convertUser(user, []uuid.UUID{createUser.OrganizationID})) } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 4ec20d66120da..188a61242c03f 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" ) @@ -123,7 +124,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { filter.OwnerUsername = "" } - workspaces, err := api.Database.GetWorkspacesWithFilter(r.Context(), filter) + workspaces, err := api.Database.GetWorkspaces(r.Context(), filter) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching workspaces.", @@ -457,6 +458,11 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } + api.Telemetry.Report(&telemetry.Snapshot{ + Workspaces: []telemetry.Workspace{telemetry.ConvertWorkspace(workspace)}, + WorkspaceBuilds: []telemetry.WorkspaceBuild{telemetry.ConvertWorkspaceBuild(workspaceBuild)}, + }) + httpapi.Write(rw, http.StatusCreated, convertWorkspace(workspace, workspaceBuild, templateVersionJob, template, findUser(apiKey.UserID, users), findUser(workspaceBuild.InitiatorID, users))) } @@ -945,11 +951,11 @@ func validWorkspaceSchedule(s *string, min time.Duration) (sql.NullString, error // workspaceSearchQuery takes a query string and returns the workspace filter. // It also can return the list of validation errors to return to the api. -func workspaceSearchQuery(query string) (database.GetWorkspacesWithFilterParams, []httpapi.Error) { +func workspaceSearchQuery(query string) (database.GetWorkspacesParams, []httpapi.Error) { searchParams := make(url.Values) if query == "" { // No filter - return database.GetWorkspacesWithFilterParams{}, nil + return database.GetWorkspacesParams{}, nil } // Because we do this in 2 passes, we want to maintain quotes on the first // pass.Further splitting occurs on the second pass and quotes will be @@ -968,14 +974,14 @@ func workspaceSearchQuery(query string) (database.GetWorkspacesWithFilterParams, searchParams.Set("owner", parts[0]) searchParams.Set("name", parts[1]) default: - return database.GetWorkspacesWithFilterParams{}, []httpapi.Error{ + return database.GetWorkspacesParams{}, []httpapi.Error{ {Field: "q", Detail: fmt.Sprintf("Query element %q can only contain 1 '/'", element)}, } } case 2: searchParams.Set(parts[0], parts[1]) default: - return database.GetWorkspacesWithFilterParams{}, []httpapi.Error{ + return database.GetWorkspacesParams{}, []httpapi.Error{ {Field: "q", Detail: fmt.Sprintf("Query element %q can only contain 1 ':'", element)}, } } @@ -984,7 +990,7 @@ func workspaceSearchQuery(query string) (database.GetWorkspacesWithFilterParams, // Using the query param parser here just returns consistent errors with // other parsing. parser := httpapi.NewQueryParamParser() - filter := database.GetWorkspacesWithFilterParams{ + filter := database.GetWorkspacesParams{ Deleted: false, OwnerUsername: parser.String(searchParams, "", "owner"), TemplateName: parser.String(searchParams, "", "template"), diff --git a/coderd/workspaces_internal_test.go b/coderd/workspaces_internal_test.go index dc783b417ebfc..2d6e2c21ee3d7 100644 --- a/coderd/workspaces_internal_test.go +++ b/coderd/workspaces_internal_test.go @@ -15,18 +15,18 @@ func TestSearchWorkspace(t *testing.T) { testCases := []struct { Name string Query string - Expected database.GetWorkspacesWithFilterParams + Expected database.GetWorkspacesParams ExpectedErrorContains string }{ { Name: "Empty", Query: "", - Expected: database.GetWorkspacesWithFilterParams{}, + Expected: database.GetWorkspacesParams{}, }, { Name: "Owner/Name", Query: "Foo/Bar", - Expected: database.GetWorkspacesWithFilterParams{ + Expected: database.GetWorkspacesParams{ OwnerUsername: "Foo", Name: "Bar", }, @@ -34,14 +34,14 @@ func TestSearchWorkspace(t *testing.T) { { Name: "Name", Query: "workspace-name", - Expected: database.GetWorkspacesWithFilterParams{ + Expected: database.GetWorkspacesParams{ Name: "workspace-name", }, }, { Name: "Name+Param", Query: "workspace-name template:docker", - Expected: database.GetWorkspacesWithFilterParams{ + Expected: database.GetWorkspacesParams{ Name: "workspace-name", TemplateName: "docker", }, @@ -49,7 +49,7 @@ func TestSearchWorkspace(t *testing.T) { { Name: "OnlyParams", Query: "name:workspace-name template:docker owner:alice", - Expected: database.GetWorkspacesWithFilterParams{ + Expected: database.GetWorkspacesParams{ Name: "workspace-name", TemplateName: "docker", OwnerUsername: "alice", @@ -58,7 +58,7 @@ func TestSearchWorkspace(t *testing.T) { { Name: "QuotedParam", Query: `name:workspace-name template:"docker template" owner:alice`, - Expected: database.GetWorkspacesWithFilterParams{ + Expected: database.GetWorkspacesParams{ Name: "workspace-name", TemplateName: "docker template", OwnerUsername: "alice", @@ -67,7 +67,7 @@ func TestSearchWorkspace(t *testing.T) { { Name: "QuotedKey", Query: `"name":baz "template":foo "owner":bar`, - Expected: database.GetWorkspacesWithFilterParams{ + Expected: database.GetWorkspacesParams{ Name: "baz", TemplateName: "foo", OwnerUsername: "bar", @@ -77,34 +77,34 @@ func TestSearchWorkspace(t *testing.T) { // This will not return an error Name: "ExtraKeys", Query: `foo:bar`, - Expected: database.GetWorkspacesWithFilterParams{}, + Expected: database.GetWorkspacesParams{}, }, { // Quotes keep elements together Name: "QuotedSpecial", Query: `name:"workspace:name"`, - Expected: database.GetWorkspacesWithFilterParams{ + Expected: database.GetWorkspacesParams{ Name: "workspace:name", }, }, { Name: "QuotedMadness", Query: `"name":"foo:bar:baz/baz/zoo:zonk"`, - Expected: database.GetWorkspacesWithFilterParams{ + Expected: database.GetWorkspacesParams{ Name: "foo:bar:baz/baz/zoo:zonk", }, }, { Name: "QuotedName", Query: `"foo/bar"`, - Expected: database.GetWorkspacesWithFilterParams{ + Expected: database.GetWorkspacesParams{ Name: "foo/bar", }, }, { Name: "QuotedOwner/Name", Query: `"foo"/"bar"`, - Expected: database.GetWorkspacesWithFilterParams{ + Expected: database.GetWorkspacesParams{ Name: "bar", OwnerUsername: "foo", }, diff --git a/go.mod b/go.mod index d278dfc669629..5f17f94fa1c1a 100644 --- a/go.mod +++ b/go.mod @@ -54,6 +54,7 @@ require ( github.com/coder/retry v1.3.0 github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf github.com/creack/pty v1.1.18 + github.com/elastic/go-sysinfo v1.8.0 github.com/fatih/color v1.13.0 github.com/fatih/structs v1.1.0 github.com/fergusstrange/embedded-postgres v1.16.0 @@ -134,9 +135,11 @@ require github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect require ( github.com/agnivade/levenshtein v1.0.1 // indirect - github.com/stretchr/objx v0.2.0 // indirect + github.com/elastic/go-windows v1.0.0 // indirect + github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 // indirect github.com/vektah/gqlparser/v2 v2.4.4 // indirect github.com/yuin/goldmark v1.4.12 // indirect + howett.net/plist v0.0.0-20181124034731-591f970eefbb // indirect ) require ( @@ -234,7 +237,6 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/tdewolff/parse/v2 v2.6.0 // indirect - github.com/vektah/gqlparser/v2 v2.4.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect diff --git a/go.sum b/go.sum index 97b4b8e376ee0..e30eaae0b8da9 100644 --- a/go.sum +++ b/go.sum @@ -516,6 +516,10 @@ github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:Htrtb github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/elastic/go-sysinfo v1.8.0 h1:hwmVlZLfTVTP+L0hSS2BD/G8GNPmcl4JEMoOktSw/wc= +github.com/elastic/go-sysinfo v1.8.0/go.mod h1:JfllUnzoQV/JRYymbH3dO1yggI3mV2oTKSXsDHM+uIM= +github.com/elastic/go-windows v1.0.0 h1:qLURgZFkkrYyTTkvYpsZIgf83AUsdIHfvlJaqaZ7aSY= +github.com/elastic/go-windows v1.0.0/go.mod h1:TsU0Nrp7/y3+VwE82FoZF8gC/XFg/Elz6CcloAxnPgU= github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= @@ -984,6 +988,7 @@ github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dv github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jedib0t/go-pretty/v6 v6.3.2 h1:+46BKrPFAyhAn3MTT3vzvZc+qvWAX23yviAlBG9zAxA= github.com/jedib0t/go-pretty/v6 v6.3.2/go.mod h1:B1WBBWnJhW9jnk7GHxY+p9NlmNwf/KUb4hKsRk6BdBQ= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= @@ -996,6 +1001,8 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52CupLJyoVwB10FQ/IQlF1pdL8= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 h1:rp+c0RAYOWj8l6qbCUTSiRLG/iKnW3K3/QfPPuSsBt4= +github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= @@ -1510,7 +1517,6 @@ github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag github.com/stretchr/objx v0.0.0-20180129172003-8a3f7159479f/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -2438,6 +2444,8 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.1.1/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= +howett.net/plist v0.0.0-20181124034731-591f970eefbb h1:jhnBjNi9UFpfpl8YZhA9CrOqpnJdvzuiHsl/dnxl11M= +howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= k8s.io/api v0.16.13/go.mod h1:QWu8UWSTiuQZMMeYjwLs6ILu5O74qKSJ0c+4vrchDxs= k8s.io/api v0.20.1/go.mod h1:KqwcCVogGxQY3nBlRpwt+wpAMF/KjaCc7RpywacvqUo= k8s.io/api v0.20.4/go.mod h1:++lNL1AJMkDymriNniQsWRkMDzRaX2Y/POTUi8yvqYQ=