Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion cmd/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type GlobalFlags struct {
Owner platform.OwnerFilter

LogOutput string
LogLevel string
ResultFormat string
Verbosity int
Quiet bool
Expand All @@ -42,6 +43,12 @@ func registerCoreFlags(flags *flag.FlagSet, globalFlags *GlobalFlags) {
"",
"If defined will override the default devsy home",
)
flags.StringVar(
&globalFlags.LogLevel,
names.LogLevel,
"",
"The log level override. Can be error, warn, info, debug, or trace",
)
flags.StringVar(&globalFlags.Context, names.Context, "", "The context to use")
flags.StringVar(
&globalFlags.Provider,
Expand Down Expand Up @@ -80,7 +87,7 @@ func registerVerbosityFlags(flags *flag.FlagSet, globalFlags *GlobalFlags) {
names.Quiet,
"q",
false,
"Suppress all log output except fatal errors",
"Suppress all log output except errors",
)
flags.BoolVar(
&globalFlags.Debug,
Expand Down
82 changes: 65 additions & 17 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/devsy-org/devsy/pkg/clierr"
"github.com/devsy-org/devsy/pkg/clihelp"
"github.com/devsy-org/devsy/pkg/config"
devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config"
"github.com/devsy-org/devsy/pkg/exitcode"
"github.com/devsy-org/devsy/pkg/flags/names"
"github.com/devsy-org/devsy/pkg/flatpak"
Expand Down Expand Up @@ -102,6 +103,31 @@ func logOutputFromArgs(args []string) string {
return ""
}

func defaultLogLevel(globalFlags *flags.GlobalFlags) string {
devsyConfig, err := config.LoadConfig(globalFlags.Context, globalFlags.Provider)
if err != nil {
return log.DefaultLevel
}
if level := devsyConfig.ContextOption(config.ContextOptionLogLevel); level != "" {
return level
}
return log.DefaultLevel
}

func validateLogLevel(level string) error {
if level == "" {
return nil
}
if _, ok := log.LevelFromString(level); !ok {
return fmt.Errorf(
"invalid log level %q, expected one of: %s",
level,
strings.Join(log.ValidLevels(), ", "),
)
}
return nil
}

func isMachineConsumer(logOutput string, isInternal bool) bool {
switch {
case isInternal:
Expand Down Expand Up @@ -190,11 +216,14 @@ func configureOutput(
}
}
log.Init(log.Config{
Verbosity: globalFlags.Verbosity,
Quiet: globalFlags.Quiet,
Debug: globalFlags.Debug,
Format: format,
Redactor: secrets.NewEnvironmentRedactor(os.Environ()),
Verbosity: globalFlags.Verbosity,
Quiet: globalFlags.Quiet,
Debug: globalFlags.Debug,
Level: globalFlags.LogLevel,
DefaultLevel: defaultLogLevel(globalFlags),
VerbositySet: rootCmd.PersistentFlags().Changed(names.Verbose),
Format: format,
Redactor: secrets.NewEnvironmentRedactor(os.Environ()),
})
return machineMode
}
Expand Down Expand Up @@ -239,13 +268,25 @@ func exitCodeForError(err error, machineMode bool) int {
func passthroughExitCode(err error, machineMode bool) (int, bool) {
if sshExitErr, ok := errors.AsType[*ssh.ExitError](err); ok {
if machineMode {
log.Errorf("SSH command failed with exit code %d", sshExitErr.ExitStatus())
renderCLIError(
clierr.Classify(fmt.Errorf(
"SSH command failed with exit code %d",
sshExitErr.ExitStatus(),
)),
true,
)
}
return sshExitErr.ExitStatus(), true
}
if execExitErr, ok := errors.AsType[*exec.ExitError](err); ok {
if machineMode {
log.Errorf("Command failed with exit code %d", execExitErr.ExitCode())
renderCLIError(
clierr.Classify(fmt.Errorf(
"command failed with exit code %d",
execExitErr.ExitCode(),
)),
true,
)
}
return execExitErr.ExitCode(), true
}
Expand All @@ -257,7 +298,9 @@ func renderCLIError(cliErr *clierr.CLIError, machineMode bool) {
return
}
if machineMode {
log.JSONError(cliErr)
if err := devcconfig.WriteCLIErrorJSON(os.Stderr, cliErr); err != nil {
log.Errorf("failed to write CLI error envelope: %v", err)
}
return
}
redactor := secrets.NewEnvironmentRedactor(os.Environ())
Expand Down Expand Up @@ -307,6 +350,12 @@ func BuildRoot() (*cobra.Command, *flags.GlobalFlags) {
_ = completion.RegisterFlagCompletionFuns(rootCmd, globalFlags)

rootCmd.PersistentPreRunE = func(cobraCmd *cobra.Command, _ []string) error {
if err := validateLogLevel(globalFlags.LogLevel); err != nil {
return err
}
if globalFlags.DevsyHome != "" {
_ = os.Setenv(config.EnvHome, globalFlags.DevsyHome)
}
cobraCmd.SilenceUsage = true
logFormat := globalFlags.LogOutput
if logFormat == "" {
Expand All @@ -318,18 +367,17 @@ func BuildRoot() (*cobra.Command, *flags.GlobalFlags) {
}

log.Init(log.Config{
Verbosity: globalFlags.Verbosity,
Quiet: globalFlags.Quiet,
Debug: globalFlags.Debug,
Format: logFormat,
Redactor: secrets.NewEnvironmentRedactor(os.Environ()),
Verbosity: globalFlags.Verbosity,
Quiet: globalFlags.Quiet,
Debug: globalFlags.Debug,
Level: globalFlags.LogLevel,
DefaultLevel: defaultLogLevel(globalFlags),
VerbositySet: persistentFlags.Changed(names.Verbose),
Format: logFormat,
Redactor: secrets.NewEnvironmentRedactor(os.Environ()),
})
klog.SetLogger(logr.New(log.LogrSink()))

if globalFlags.DevsyHome != "" {
_ = os.Setenv(config.EnvHome, globalFlags.DevsyHome)
}

devsyConfig, err := config.LoadConfig(globalFlags.Context, globalFlags.Provider)
if err == nil {
current := telemetry.FromContext(cobraCmd.Context())
Expand Down
44 changes: 44 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/devsy-org/devsy/pkg/clierr"
"github.com/devsy-org/devsy/pkg/config"
devcconfig "github.com/devsy-org/devsy/pkg/devcontainer/config"
"github.com/devsy-org/devsy/pkg/exitcode"
"github.com/devsy-org/devsy/pkg/log"
"github.com/devsy-org/devsy/pkg/workspace"
Expand Down Expand Up @@ -50,6 +51,49 @@ func TestRenderCLIErrorRedactsEnvironmentSecrets(t *testing.T) {
assert.Contains(t, output, "***")
}

func TestRenderCLIErrorMachineModeWritesDirectEnvelope(t *testing.T) {
r, w, err := os.Pipe()
require.NoError(t, err)
original := os.Stderr
os.Stderr = w
renderCLIError(&clierr.CLIError{Code: clierr.CodeUnknown, Message: "machine failure"}, true)
require.NoError(t, w.Close())
os.Stderr = original
var envelope struct {
Kind string `json:"kind"`
Outcome string `json:"outcome"`
Message string `json:"message"`
}
require.NoError(t, json.NewDecoder(r).Decode(&envelope))
assert.Equal(t, "error", envelope.Kind)
assert.Equal(t, "error", envelope.Outcome)
assert.Equal(t, "machine failure", envelope.Message)
}

func TestRenderCLIErrorMachineModeIgnoresDiagnosticLevel(t *testing.T) {
for _, level := range log.ValidLevels() {
t.Run(level, func(t *testing.T) {
log.Init(log.Config{Level: level})
r, w, err := os.Pipe()
require.NoError(t, err)
original := os.Stderr
os.Stderr = w
renderCLIError(
&clierr.CLIError{Code: clierr.CodeUnknown, Message: "machine failure"},
true,
)
require.NoError(t, w.Close())
os.Stderr = original

var envelope devcconfig.ErrorEnvelope
require.NoError(t, json.NewDecoder(r).Decode(&envelope))
assert.Equal(t, "error", envelope.Kind)
assert.Equal(t, "error", envelope.Outcome)
assert.Equal(t, "machine failure", envelope.Message)
})
}
}

func TestTopLevelCommand(t *testing.T) {
rootCmd, _ := BuildRoot()

Expand Down
21 changes: 20 additions & 1 deletion desktop/e2e/fixtures/mock-devsy.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,20 @@ function parseArgs(args) {
let providerFlag = ""
let ideFlag = ""
let nameFlag = ""
let logLevel = "warn"
let i = 0
while (i < args.length) {
const arg = args[i]
if (arg === "--log-level") {
logLevel = args[i + 1] || "warn"
i += 2
continue
}
if (arg.startsWith("--log-level=")) {
logLevel = arg.slice("--log-level=".length)
i++
continue
}
if (arg === "--result-format") {
i += 2
continue
Expand Down Expand Up @@ -194,7 +205,14 @@ function parseArgs(args) {
positional.push(arg)
i++
}
return { positional, idFlag, providerFlag, ideFlag, nameFlag }
return { positional, idFlag, providerFlag, ideFlag, nameFlag, logLevel }
}

function emitDiagnostics(logLevel) {
const levels = { error: 0, warn: 1, info: 2, debug: 3, trace: 4 }
const selected = levels[logLevel] ?? levels.warn
if (selected >= levels.info) process.stderr.write("INFO diagnostic line\n")
if (selected >= levels.warn) process.stderr.write("WARN diagnostic line\n")
}

// Workspace verb handlers. Each accepts the slice of args AFTER its verb,
Expand Down Expand Up @@ -570,6 +588,7 @@ const featureHandlers = {
}

const cmd = rawArgs[0] || ""
emitDiagnostics(parseArgs(rawArgs).logLevel)

// Canonical form: `devsy feature <verb> ...`
if (cmd === "feature") {
Expand Down
69 changes: 69 additions & 0 deletions desktop/e2e/log-level-protocol.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { ElectronApplication, Page } from "@playwright/test"
import { expect, test } from "@playwright/test"
import { launchApp, resetMockState } from "./electron-app.js"

let app: ElectronApplication
let page: Page

async function invoke(channel: string, args?: Record<string, unknown>) {
return page.evaluate(
({ channel, args }) => window.electronAPI.invoke(channel, args),
{ channel, args },
)
}

async function readLatestLog(workspaceId: string): Promise<string> {
const logs = (await invoke("workspace_logs_list", { workspaceId })) as Array<{
filename: string
}>
const latest = logs[0]
return latest
? ((await invoke("workspace_log_read", {
workspaceId,
filename: latest.filename,
})) as string)
: ""
}

test.beforeAll(async () => {
resetMockState()
;({ app, page } = await launchApp())
})

test.afterAll(async () => {
await app?.close()
})

test("status and result protocols are independent of CLI diagnostic level", async () => {
test.setTimeout(120000)
for (const [index, level] of (["info", "warn", "error"] as const).entries()) {
const workspaceId = `protocol-${index}`
await invoke("set_app_settings", { patch: { cliCaptureLogLevel: level } })
await invoke("workspace_up", {
source: `https://example.com/${workspaceId}.git`,
workspaceId,
})
await expect
.poll(async () => {
const snapshot = (await invoke("workspace_snapshot")) as {
jobs: Record<string, { state: string }>
}
return snapshot.jobs[workspaceId]?.state
}, { timeout: 30000 })
.toBe("succeeded")
await expect
.poll(
async () => {
const log = await readLatestLog(workspaceId)
return (
log.includes("INFO diagnostic line") === (level === "info") &&
log.includes("WARN diagnostic line") === (level !== "error")
)
},
{ timeout: 10000 },
)
.toBe(true)
const workspaces = (await invoke("workspace_list")) as Array<{ id: string; status: string }>
expect(workspaces.find((workspace) => workspace.id === workspaceId)?.status).toBe("Running")
}
})
Loading
Loading