feat: Add anonymized telemetry to report product usage (#2273)

* feat: Add anonymized telemetry to report product usage

This adds a background service to report telemetry to a Coder
server for usage data. There will be realtime event data sent
in the future, but for now usage will report on a CRON.

* Fix flake and requested changes

* Add reporting options for setup

* Add reporting for workspaces

* Add resources as they are reported

* Track API key usage

* Ensure telemetry is tracked prior to exit
This commit is contained in:
Kyle Carberry 2022-06-17 00:26:40 -05:00 committed by GitHub
parent af8a1e3fea
commit 4cce969018
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1674 additions and 95 deletions

View File

@ -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. "+

View File

@ -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) {

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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);

View File

@ -0,0 +1 @@
DROP TABLE site_configs;

View File

@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS site_configs (
key varchar(256) NOT NULL UNIQUE,
value varchar(8192) NOT NULL
);

View File

@ -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"`

View File

@ -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)

View File

@ -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 (

View File

@ -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 (

View File

@ -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 (

View File

@ -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 (

View File

@ -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';

View File

@ -48,6 +48,9 @@ WHERE
LIMIT
1;
-- name: GetTemplates :many
SELECT * FROM templates;
-- name: InsertTemplate :one
INSERT INTO
templates (

View File

@ -40,6 +40,9 @@ FROM
WHERE
job_id = $1;
-- name: GetTemplateVersionsCreatedAfter :many
SELECT * FROM template_versions WHERE created_at > $1;
-- name: GetTemplateVersionByTemplateIDAndName :one
SELECT
*

View File

@ -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 (

View File

@ -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 (

View File

@ -18,6 +18,9 @@ WHERE
LIMIT
1;
-- name: GetWorkspaceBuildsCreatedAfter :many
SELECT * FROM workspace_builds WHERE created_at > $1;
-- name: GetWorkspaceBuildByWorkspaceIDAndName :one
SELECT
*

View File

@ -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)

View File

@ -8,7 +8,7 @@ WHERE
LIMIT
1;
-- name: GetWorkspacesWithFilter :many
-- name: GetWorkspaces :many
SELECT
*
FROM

View File

@ -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

View File

@ -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() {}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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}))
}

View File

@ -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"),

View File

@ -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",
},

6
go.mod
View File

@ -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

10
go.sum
View File

@ -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=