diff --git a/cli/deployment/config.go b/cli/deployment/config.go index 69451de3a16e3..02b24f371ce6f 100644 --- a/cli/deployment/config.go +++ b/cli/deployment/config.go @@ -471,6 +471,26 @@ func newConfig() *codersdk.DeploymentConfig { Default: false, }, }, + Logging: &codersdk.LoggingConfig{ + Human: &codersdk.DeploymentConfigField[string]{ + Name: "Human Log Location", + Usage: "Output human-readable logs to a given file.", + Flag: "log-human", + Default: "/dev/stderr", + }, + JSON: &codersdk.DeploymentConfigField[string]{ + Name: "JSON Log Location", + Usage: "Output JSON logs to a given file.", + Flag: "log-json", + Default: "", + }, + Stackdriver: &codersdk.DeploymentConfigField[string]{ + Name: "Stackdriver Log Location", + Usage: "Output Stackdriver compatible logs to a given file.", + Flag: "log-stackdriver", + Default: "", + }, + }, } } diff --git a/cli/server.go b/cli/server.go index a543f55a0e8c6..bd8b7fcdf4574 100644 --- a/cli/server.go +++ b/cli/server.go @@ -46,6 +46,8 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" + "cdr.dev/slog/sloggers/slogjson" + "cdr.dev/slog/sloggers/slogstackdriver" "github.com/coder/coder/buildinfo" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/cli/config" @@ -122,13 +124,11 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co } printLogo(cmd) - logger := slog.Make(sloghuman.Sink(cmd.ErrOrStderr())) - if ok, _ := cmd.Flags().GetBool(varVerbose); ok { - logger = logger.Leveled(slog.LevelDebug) - } - if cfg.Trace.CaptureLogs.Value { - logger = logger.AppendSinks(tracing.SlogSink{}) + logger, logCloser, err := buildLogger(cmd, cfg) + if err != nil { + return xerrors.Errorf("make logger: %w", err) } + defer logCloser() // Register signals early on so that graceful shutdown can't // be interrupted by additional signals. Note that we avoid @@ -1145,6 +1145,11 @@ func newProvisionerDaemon( // nolint: revive func printLogo(cmd *cobra.Command) { + // Only print the logo in TTYs. + if !isTTYOut(cmd) { + return + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s - Software development on your infrastucture\n", cliui.Styles.Bold.Render("Coder "+buildinfo.Version())) } @@ -1512,3 +1517,64 @@ func redirectHTTPToAccessURL(handler http.Handler, accessURL *url.URL) http.Hand func isLocalhost(host string) bool { return host == "localhost" || host == "127.0.0.1" || host == "::1" } + +func buildLogger(cmd *cobra.Command, cfg *codersdk.DeploymentConfig) (slog.Logger, func(), error) { + var ( + sinks = []slog.Sink{} + closers = []func() error{} + ) + + addSinkIfProvided := func(sinkFn func(io.Writer) slog.Sink, loc string) error { + switch loc { + case "": + + case "/dev/stdout": + sinks = append(sinks, sinkFn(cmd.OutOrStdout())) + + case "/dev/stderr": + sinks = append(sinks, sinkFn(cmd.ErrOrStderr())) + + default: + fi, err := os.OpenFile(loc, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) + if err != nil { + return xerrors.Errorf("open log file %q: %w", loc, err) + } + + closers = append(closers, fi.Close) + sinks = append(sinks, sinkFn(fi)) + } + return nil + } + + err := addSinkIfProvided(sloghuman.Sink, cfg.Logging.Human.Value) + if err != nil { + return slog.Logger{}, nil, xerrors.Errorf("add human sink: %w", err) + } + err = addSinkIfProvided(slogjson.Sink, cfg.Logging.JSON.Value) + if err != nil { + return slog.Logger{}, nil, xerrors.Errorf("add json sink: %w", err) + } + err = addSinkIfProvided(slogstackdriver.Sink, cfg.Logging.Stackdriver.Value) + if err != nil { + return slog.Logger{}, nil, xerrors.Errorf("add stackdriver sink: %w", err) + } + + if cfg.Trace.CaptureLogs.Value { + sinks = append(sinks, tracing.SlogSink{}) + } + + level := slog.LevelInfo + if ok, _ := cmd.Flags().GetBool(varVerbose); ok { + level = slog.LevelDebug + } + + if len(sinks) == 0 { + return slog.Logger{}, nil, xerrors.New("no loggers provided") + } + + return slog.Make(sinks...).Leveled(level), func() { + for _, closer := range closers { + _ = closer() + } + }, nil +} diff --git a/cli/server_test.go b/cli/server_test.go index 32b1c10ceebc9..6cd13cb32e2ce 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -35,6 +35,7 @@ import ( "github.com/coder/coder/coderd/database/postgres" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/codersdk" + "github.com/coder/coder/cryptorand" "github.com/coder/coder/pty/ptytest" "github.com/coder/coder/testutil" ) @@ -1122,6 +1123,194 @@ func TestServer(t *testing.T) { <-serverErr }) }) + + t.Run("Logging", func(t *testing.T) { + t.Parallel() + + t.Run("CreatesFile", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + random, err := cryptorand.String(5) + require.NoError(t, err) + fiName := fmt.Sprint(os.TempDir(), "/coder-logging-test-", random) + defer func() { + _ = os.Remove(fiName) + }() + + root, _ := clitest.New(t, + "server", + "--verbose", + "--in-memory", + "--http-address", ":0", + "--access-url", "http://example.com", + "--log-human", fiName, + ) + serverErr := make(chan error, 1) + go func() { + serverErr <- root.ExecuteContext(ctx) + }() + + assert.Eventually(t, func() bool { + stat, err := os.Stat(fiName) + return err == nil && stat.Size() > 0 + }, testutil.WaitShort, testutil.IntervalFast) + cancelFunc() + <-serverErr + }) + + t.Run("Human", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + fi, err := os.CreateTemp("", "coder-logging-test-*") + require.NoError(t, err) + defer func() { + _ = os.Remove(fi.Name()) + }() + + root, _ := clitest.New(t, + "server", + "--verbose", + "--in-memory", + "--http-address", ":0", + "--access-url", "http://example.com", + "--log-human", fi.Name(), + ) + serverErr := make(chan error, 1) + go func() { + serverErr <- root.ExecuteContext(ctx) + }() + + assert.Eventually(t, func() bool { + stat, err := os.Stat(fi.Name()) + return err == nil && stat.Size() > 0 + }, testutil.WaitShort, testutil.IntervalFast) + cancelFunc() + <-serverErr + }) + + t.Run("JSON", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + fi, err := os.CreateTemp("", "coder-logging-test-*") + require.NoError(t, err) + defer func() { + _ = os.Remove(fi.Name()) + }() + + root, _ := clitest.New(t, + "server", + "--verbose", + "--in-memory", + "--http-address", ":0", + "--access-url", "http://example.com", + "--log-json", fi.Name(), + ) + serverErr := make(chan error, 1) + go func() { + serverErr <- root.ExecuteContext(ctx) + }() + + assert.Eventually(t, func() bool { + stat, err := os.Stat(fi.Name()) + return err == nil && stat.Size() > 0 + }, testutil.WaitShort, testutil.IntervalFast) + cancelFunc() + <-serverErr + }) + + t.Run("Stackdriver", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + fi, err := os.CreateTemp("", "coder-logging-test-*") + require.NoError(t, err) + defer func() { + _ = os.Remove(fi.Name()) + }() + + root, _ := clitest.New(t, + "server", + "--verbose", + "--in-memory", + "--http-address", ":0", + "--access-url", "http://example.com", + "--log-stackdriver", fi.Name(), + ) + serverErr := make(chan error, 1) + go func() { + serverErr <- root.ExecuteContext(ctx) + }() + + assert.Eventually(t, func() bool { + stat, err := os.Stat(fi.Name()) + return err == nil && stat.Size() > 0 + }, testutil.WaitLong, testutil.IntervalMedium) + cancelFunc() + <-serverErr + }) + + t.Run("Multiple", func(t *testing.T) { + t.Parallel() + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + fi1, err := os.CreateTemp("", "coder-logging-test-*") + require.NoError(t, err) + defer func() { + _ = os.Remove(fi1.Name()) + }() + + fi2, err := os.CreateTemp("", "coder-logging-test-*") + require.NoError(t, err) + defer func() { + _ = os.Remove(fi2.Name()) + }() + + fi3, err := os.CreateTemp("", "coder-logging-test-*") + require.NoError(t, err) + defer func() { + _ = os.Remove(fi3.Name()) + }() + + root, _ := clitest.New(t, + "server", + "--verbose", + "--in-memory", + "--http-address", ":0", + "--access-url", "http://example.com", + "--log-human", fi1.Name(), + "--log-json", fi2.Name(), + "--log-stackdriver", fi3.Name(), + ) + serverErr := make(chan error, 1) + go func() { + serverErr <- root.ExecuteContext(ctx) + }() + + assert.Eventually(t, func() bool { + stat, err := os.Stat(fi1.Name()) + return err == nil && stat.Size() > 0 + }, testutil.WaitLong, testutil.IntervalMedium) + assert.Eventually(t, func() bool { + stat, err := os.Stat(fi2.Name()) + return err == nil && stat.Size() > 0 + }, testutil.WaitLong, testutil.IntervalMedium) + assert.Eventually(t, func() bool { + stat, err := os.Stat(fi3.Name()) + return err == nil && stat.Size() > 0 + }, testutil.WaitLong, testutil.IntervalMedium) + + cancelFunc() + <-serverErr + }) + }) } func generateTLSCertificate(t testing.TB, commonName ...string) (certPath, keyPath string) { diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 49f2da97c55b0..0424c2c8dd9f1 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -70,6 +70,15 @@ Flags: disable the HTTP endpoint. Consumes $CODER_HTTP_ADDRESS (default "127.0.0.1:3000") + --log-human string Output human-readable logs to a given + file. + Consumes $CODER_LOGGING_HUMAN (default + "/dev/stderr") + --log-json string Output JSON logs to a given file. + Consumes $CODER_LOGGING_JSON + --log-stackdriver string Output Stackdriver compatible logs to a + given file. + Consumes $CODER_LOGGING_STACKDRIVER --max-token-lifetime duration The maximum lifetime duration for any user creating a token. Consumes $CODER_MAX_TOKEN_LIFETIME diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c103fa9ca0ec9..b6d9843bd7c92 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5362,6 +5362,9 @@ const docTemplate = `{ "in_memory_database": { "$ref": "#/definitions/codersdk.DeploymentConfigField-bool" }, + "logging": { + "$ref": "#/definitions/codersdk.LoggingConfig" + }, "max_token_lifetime": { "$ref": "#/definitions/codersdk.DeploymentConfigField-time_Duration" }, @@ -5881,6 +5884,20 @@ const docTemplate = `{ "LogSourceProvisioner" ] }, + "codersdk.LoggingConfig": { + "type": "object", + "properties": { + "human": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-string" + }, + "json": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-string" + }, + "stackdriver": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-string" + } + } + }, "codersdk.LoginType": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ae8733e3a44fe..2f78661e39906 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4757,6 +4757,9 @@ "in_memory_database": { "$ref": "#/definitions/codersdk.DeploymentConfigField-bool" }, + "logging": { + "$ref": "#/definitions/codersdk.LoggingConfig" + }, "max_token_lifetime": { "$ref": "#/definitions/codersdk.DeploymentConfigField-time_Duration" }, @@ -5258,6 +5261,20 @@ "enum": ["provisioner_daemon", "provisioner"], "x-enum-varnames": ["LogSourceProvisionerDaemon", "LogSourceProvisioner"] }, + "codersdk.LoggingConfig": { + "type": "object", + "properties": { + "human": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-string" + }, + "json": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-string" + }, + "stackdriver": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-string" + } + } + }, "codersdk.LoginType": { "type": "string", "enum": ["password", "github", "oidc", "token"], diff --git a/codersdk/deploymentconfig.go b/codersdk/deploymentconfig.go index 6842f15a66c23..477a27b013e6c 100644 --- a/codersdk/deploymentconfig.go +++ b/codersdk/deploymentconfig.go @@ -45,6 +45,7 @@ type DeploymentConfig struct { UpdateCheck *DeploymentConfigField[bool] `json:"update_check" typescript:",notnull"` MaxTokenLifetime *DeploymentConfigField[time.Duration] `json:"max_token_lifetime" typescript:",notnull"` Swagger *SwaggerConfig `json:"swagger" typescript:",notnull"` + Logging *LoggingConfig `json:"logging" typescript:",notnull"` } type DERP struct { @@ -155,6 +156,12 @@ type SwaggerConfig struct { Enable *DeploymentConfigField[bool] `json:"enable" typescript:",notnull"` } +type LoggingConfig struct { + Human *DeploymentConfigField[string] `json:"human" typescript:",notnull"` + JSON *DeploymentConfigField[string] `json:"json" typescript:",notnull"` + Stackdriver *DeploymentConfigField[string] `json:"stackdriver" typescript:",notnull"` +} + type Flaggable interface { string | time.Duration | bool | int | []string | []GitAuthConfig } diff --git a/docs/api/general.md b/docs/api/general.md index 85167d8dd2ee9..578ad62ebc6e7 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -335,6 +335,41 @@ curl -X GET http://coder-server:8080/api/v2/config/deployment \ "usage": "string", "value": true }, + "logging": { + "human": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + }, + "json": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + }, + "stackdriver": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + } + }, "max_token_lifetime": { "default": 0, "enterprise": true, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index ed546daf87ca8..a4d497a05cf59 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1408,6 +1408,41 @@ CreateParameterRequest is a structure used to create a new parameter value for a "usage": "string", "value": true }, + "logging": { + "human": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + }, + "json": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + }, + "stackdriver": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + } + }, "max_token_lifetime": { "default": 0, "enterprise": true, @@ -2022,6 +2057,7 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `gitauth` | [codersdk.DeploymentConfigField-array_codersdk_GitAuthConfig](#codersdkdeploymentconfigfield-array_codersdk_gitauthconfig) | false | | | | `http_address` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | | `in_memory_database` | [codersdk.DeploymentConfigField-bool](#codersdkdeploymentconfigfield-bool) | false | | | +| `logging` | [codersdk.LoggingConfig](#codersdkloggingconfig) | false | | | | `max_token_lifetime` | [codersdk.DeploymentConfigField-time_Duration](#codersdkdeploymentconfigfield-time_duration) | false | | | | `metrics_cache_refresh_interval` | [codersdk.DeploymentConfigField-time_Duration](#codersdkdeploymentconfigfield-time_duration) | false | | | | `oauth2` | [codersdk.OAuth2Config](#codersdkoauth2config) | false | | | @@ -2558,6 +2594,54 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `provisioner_daemon` | | `provisioner` | +## codersdk.LoggingConfig + +```json +{ + "human": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + }, + "json": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + }, + "stackdriver": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------- | ------------------------------------------------------------------------------ | -------- | ------------ | ----------- | +| `human` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | +| `json` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | +| `stackdriver` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | + ## codersdk.LoginType ```json diff --git a/go.mod b/go.mod index 393efd50842a5..2711bd2cdf93b 100644 --- a/go.mod +++ b/go.mod @@ -167,6 +167,8 @@ require ( tailscale.com v1.32.2 ) +require cloud.google.com/go/longrunning v0.1.1 // indirect + require ( cloud.google.com/go/compute v1.12.1 // indirect filippo.io/edwards25519 v1.0.0-rc.1 // indirect diff --git a/go.sum b/go.sum index 03f2351aeabd4..2c22b89fa4d7b 100644 --- a/go.sum +++ b/go.sum @@ -38,7 +38,6 @@ cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0c cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc= -cloud.google.com/go v0.105.0 h1:DNtEKRBAAzeS4KyIory52wWHuClNaXJ5x1F7xa4q+5Y= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= @@ -61,6 +60,7 @@ cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqCl cloud.google.com/go/firestore v1.6.0/go.mod h1:afJwI0vaXwAG54kI7A//lP/lSPDkQORQuMkv56TxEPU= cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= cloud.google.com/go/longrunning v0.1.1 h1:y50CXG4j0+qvEukslYFBCrzaXX0qpFbBzc3PchSu/LE= +cloud.google.com/go/longrunning v0.1.1/go.mod h1:UUFxuDWkv22EuY93jjmDMFT5GPQKeFVJBIF6QlTqdsE= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 78015f4376c43..7570a7d9289de 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -313,6 +313,7 @@ export interface DeploymentConfig { readonly update_check: DeploymentConfigField readonly max_token_lifetime: DeploymentConfigField readonly swagger: SwaggerConfig + readonly logging: LoggingConfig } // From codersdk/deploymentconfig.go @@ -421,6 +422,13 @@ export interface ListeningPortsResponse { readonly ports: ListeningPort[] } +// From codersdk/deploymentconfig.go +export interface LoggingConfig { + readonly human: DeploymentConfigField + readonly json: DeploymentConfigField + readonly stackdriver: DeploymentConfigField +} + // From codersdk/users.go export interface LoginWithPasswordRequest { readonly email: string