Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
49b0b6c
feat(trace): add local storage mode that records sessions without a c…
matiasinsaurralde Oct 14, 2026
23b1dfe
fix(trace): size the merged-annotations map from one length
matiasinsaurralde Oct 14, 2026
cefc946
feat(trace): name local mode in the session-start banner
matiasinsaurralde Oct 14, 2026
f05dd48
fix(trace): address review feedback on local storage mode
matiasinsaurralde Oct 14, 2026
fbb738e
fix(trace): clear stale local mode on control-plane init and correct …
matiasinsaurralde Oct 14, 2026
e7e4e24
fix(trace): fail trace init on an unreadable traceStorage value
matiasinsaurralde Oct 14, 2026
95c1353
feat(trace): announce the switch back to control-plane mode and docum…
matiasinsaurralde Oct 14, 2026
db6b419
feat(trace): online and offline trace modes that can run together
jiparis Oct 15, 2026
da0d275
Revert "feat(trace): online and offline trace modes that can run toge…
jiparis Oct 15, 2026
87da772
fix(trace): do not tell teammates to run trace init --local
jiparis Oct 15, 2026
020af50
fix(trace): write the local-mode texts in Simplified Technical English
jiparis Oct 15, 2026
69e74fc
feat(trace): suggest local mode when trace init is not authenticated
jiparis Oct 15, 2026
de543f3
feat(trace): make tracePath the trace directory in every mode
jiparis Oct 15, 2026
33bda24
fix(trace): report a partial local store and qualify the mode help
jiparis Oct 15, 2026
2ac1bd6
feat(trace): add --local to trace run
jiparis Oct 15, 2026
d32f5d0
Merge remote-tracking branch 'upstream/main' into matias/issue-3582-t…
jiparis Oct 15, 2026
c1e041b
feat(trace): index local sessions with an unsigned in-toto statement
jiparis Oct 15, 2026
5cba713
docs(spec): record the implementation decisions of trace local mode
jiparis Oct 15, 2026
ef6f63f
test(trace): use constants for repeated flag fixtures
jiparis Oct 15, 2026
256757f
refactor(trace): rename the materials folder of a stored session to e…
jiparis Oct 15, 2026
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
192 changes: 106 additions & 86 deletions app/cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ const (
confirmWhenUserToken = "confirmWhenUserToken"
// Skip ActionOpts initialization for commands that operate locally
skipActionOptsInit = "skipActionOptsInit"
appName = "chainloop"
// reportTelemetryAnyway lets a command annotated skipActionOptsInit opt back
// into telemetry at runtime, once it knows it ran against the control plane.
// `trace run` is skipActionOptsInit so a local-mode run opens no connection,
// but a control-plane run of it must still be reported as it was before.
reportTelemetryAnyway = "reportTelemetryAnyway"
appName = "chainloop"
//nolint:gosec
tokenEnvVarName = "CHAINLOOP_TOKEN"
// Follow the convention stated on https://consoledonottrack.com/
Expand Down Expand Up @@ -147,6 +152,101 @@ func handleOrganizationExecutionError(executed *cobra.Command, err error) error
return err
}

// ensureActionOpts builds the control-plane connection and the shared
// ActionOpts from the resolved configuration: the auth token, an optional
// federated token, the gRPC options, the current-organization lookup, and the
// interactive-token warning. It is the control-plane half of the root
// PersistentPreRunE, factored out so a command annotated skipActionOptsInit can
// skip it entirely and, when it does need the control plane, call this lazily
// instead. A trace command in LOCAL storage mode never calls it, so it makes no
// control-plane request (R-005). It sets the package-level ActionOpts.
func ensureActionOpts(cmd *cobra.Command) error {
if apiInsecure() {
logger.Debug().Msg("API contacted in insecure mode")
}

authToken, isUserToken, err := loadAuthToken(cmd)
if err != nil {
return err
}
authTokenIsUser = isUserToken

// If the auth token is not set and the command supports federated auth, we try to discover the runner and use the federated token for the runner if available
if authToken == "" && cmdSupportsFederatedAuth(cmd) {
r := crafter.DiscoverRunner(authToken, logger)
if r.IsAuthenticated() && r.FederatedToken() != "" {
logger.Debug().Str("runner", r.ID().String()).Msg("using federated auth token")
authToken = r.FederatedToken()
// reset isUserToken to false because we are using a federated token and we don't want to ask for confirmation
isUserToken = false
}
}

var opts = []grpcconn.Option{
grpcconn.WithInsecure(apiInsecure()),
grpcconn.WithCLIVersion(fullVersion()),
}

if maxRecv := apiMaxRecvMsgSize(); maxRecv > 0 {
opts = append(opts, grpcconn.WithMaxRecvMsgSize(maxRecv))
}

if caValue := viper.GetString(confOptions.controlplaneCA.viperKey); caValue != "" {
// Check if the value is a file path, if it is we read the content and encode it to base64, if not we assume it's the content already
if _, err := os.Stat(caValue); err == nil {
opts = append(opts, grpcconn.WithCAFile(caValue))
} else {
opts = append(opts, grpcconn.WithCAContent(caValue))
}
}

controlplaneURL := viper.GetString(confOptions.controlplaneAPI.viperKey)

// If no organization is selected, load it from the server and save it.
orgName := effectiveOrganization(cmd)
if orgName == "" {
conn, err := grpcconn.New(controlplaneURL, authToken, opts...)
if err != nil {
return err
}

currentContext, err := action.NewConfigCurrentContext(newActionOpts(logger, conn, authToken)).Run()
if err == nil && currentContext.CurrentMembership != nil {
if err := setLocalOrganization(currentContext.CurrentMembership.Org.Name); err != nil {
return fmt.Errorf("writing config file: %w", err)
}
}
}

// Reload the connection now that we have the organization name.
orgName = effectiveOrganization(cmd)
if orgName != "" {
opts = append(opts, grpcconn.WithOrgName(orgName))
}

// Warn users when the session is interactive, and the operation is supposed to use an API token instead
if shouldAskForConfirmation(cmd) && authToken != "" && isUserToken && !flagYes {
logger.Warn().Msg("User-attended mode detected. This is intended for local testing only. For CI/CD or automated workflows, please use an API token.")
if !confirmationPrompt(fmt.Sprintf("This command will run against the organization %q", orgName)) {
return errors.New("command canceled by user")
}
}

conn, err := grpcconn.New(controlplaneURL, authToken, opts...)
if err != nil {
return err
}

ActionOpts = newActionOpts(logger, conn, authToken)

// Add custom builtins to rego engine
if err = builtins.RegisterDiscoverBuiltin(conn); err != nil {
return fmt.Errorf("failed to register discover builtin: %w", err)
}

return nil
}

func NewRootCmd(l zerolog.Logger) *cobra.Command {
// Seed the root logger here and not only in PersistentPreRunE: cobra
// rejects unknown subcommands and invalid flags before that hook runs, and
Expand Down Expand Up @@ -175,95 +275,15 @@ Command reference: ` + cliReferenceURL,
logger.Debug().Str("path", viper.ConfigFileUsed()).Msg("using config file")

// Commands annotated with skipActionOptsInit don't need ActionOpts initialization
// These are local-only commands that don't interact with the control plane
// These are local-only commands that don't interact with the control plane.
// The trace pre-push hook and `trace init` carry the annotation and build
// ActionOpts lazily with ensureActionOpts, only in their control-plane
// branch, so a LOCAL-mode repository makes no control-plane call at all.
if cmd.Annotations[skipActionOptsInit] == trueString {
return nil
}

if apiInsecure() {
logger.Debug().Msg("API contacted in insecure mode")
}

authToken, isUserToken, err := loadAuthToken(cmd)
if err != nil {
return err
}
authTokenIsUser = isUserToken

// If the auth token is not set and the command supports federated auth, we try to discover the runner and use the federated token for the runner if available
if authToken == "" && cmdSupportsFederatedAuth(cmd) {
r := crafter.DiscoverRunner(authToken, logger)
if r.IsAuthenticated() && r.FederatedToken() != "" {
logger.Debug().Str("runner", r.ID().String()).Msg("using federated auth token")
authToken = r.FederatedToken()
// reset isUserToken to false because we are using a federated token and we don't want to ask for confirmation
isUserToken = false
}
}

var opts = []grpcconn.Option{
grpcconn.WithInsecure(apiInsecure()),
grpcconn.WithCLIVersion(fullVersion()),
}

if maxRecv := apiMaxRecvMsgSize(); maxRecv > 0 {
opts = append(opts, grpcconn.WithMaxRecvMsgSize(maxRecv))
}

if caValue := viper.GetString(confOptions.controlplaneCA.viperKey); caValue != "" {
// Check if the value is a file path, if it is we read the content and encode it to base64, if not we assume it's the content already
if _, err := os.Stat(caValue); err == nil {
opts = append(opts, grpcconn.WithCAFile(caValue))
} else {
opts = append(opts, grpcconn.WithCAContent(caValue))
}
}

controlplaneURL := viper.GetString(confOptions.controlplaneAPI.viperKey)

// If no organization is selected, load it from the server and save it.
orgName := effectiveOrganization(cmd)
if orgName == "" {
conn, err := grpcconn.New(controlplaneURL, authToken, opts...)
if err != nil {
return err
}

currentContext, err := action.NewConfigCurrentContext(newActionOpts(logger, conn, authToken)).Run()
if err == nil && currentContext.CurrentMembership != nil {
if err := setLocalOrganization(currentContext.CurrentMembership.Org.Name); err != nil {
return fmt.Errorf("writing config file: %w", err)
}
}
}

// Reload the connection now that we have the organization name.
orgName = effectiveOrganization(cmd)
if orgName != "" {
opts = append(opts, grpcconn.WithOrgName(orgName))
}

// Warn users when the session is interactive, and the operation is supposed to use an API token instead
if shouldAskForConfirmation(cmd) && authToken != "" && isUserToken && !flagYes {
logger.Warn().Msg("User-attended mode detected. This is intended for local testing only. For CI/CD or automated workflows, please use an API token.")
if !confirmationPrompt(fmt.Sprintf("This command will run against the organization %q", orgName)) {
return errors.New("command canceled by user")
}
}

conn, err := grpcconn.New(controlplaneURL, authToken, opts...)
if err != nil {
return err
}

ActionOpts = newActionOpts(logger, conn, authToken)

// Add custom builtins to rego engine
if err = builtins.RegisterDiscoverBuiltin(conn); err != nil {
return fmt.Errorf("failed to register discover builtin: %w", err)
}

return nil
return ensureActionOpts(cmd)
},
PersistentPostRunE: func(_ *cobra.Command, _ []string) error {
if ActionOpts != nil {
Expand Down
9 changes: 8 additions & 1 deletion app/cli/cmd/telemetry_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,19 @@ func telemetryIdentity() *token.ParsedToken {
// totals with something the user never ran.
//
// Beyond that, commands annotated skipActionOptsInit are the local-only ones, such as the
// git hooks, which have never been reported.
// git hooks, which have never been reported. A command can opt back in at runtime with
// reportTelemetryAnyway once it knows it ran against the control plane: `trace run` is
// skipActionOptsInit so a local-mode run opens no connection, yet a control-plane run of
// it must still be reported as it was before local mode existed.
func shouldReportCommand(executed *cobra.Command) bool {
if executed == nil || isTelemetryCommand(executed) || isCompletionCommand(executed) {
return false
}

if executed.Annotations[reportTelemetryAnyway] == trueString {
return true
}

return executed.Annotations[skipActionOptsInit] != trueString
}

Expand Down
7 changes: 7 additions & 0 deletions app/cli/cmd/telemetry_report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,13 @@ func TestShouldReportCommand(t *testing.T) {
name: "local-only command is not reported",
annotations: map[string]string{skipActionOptsInit: trueString},
},
{
// A control-plane `trace run` is skipActionOptsInit for lazy
// connection setup but opts back into reporting at runtime.
name: "skipActionOptsInit with reportTelemetryAnyway is reported",
annotations: map[string]string{skipActionOptsInit: trueString, reportTelemetryAnyway: trueString},
want: true,
},
}

for _, tc := range testCases {
Expand Down
25 changes: 24 additions & 1 deletion app/cli/cmd/trace_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func applyPrePushPolicy(executed *cobra.Command, err error) error {
return err
}

return action.PrePushFailure(err, config.LoadRequireTraceFromYML("."), logger)
return action.PrePushFailure(err, config.LoadTraceRequiredFromYML("."), logger)
}

func newTraceHookGitPrePushCmd() *cobra.Command {
Expand All @@ -121,12 +121,35 @@ func newTraceHookGitPrePushCmd() *cobra.Command {
Short: "Handle the pre-push git hook",
// Any error of this command, setup included, goes through
// action.PrePushFailure in Execute.
//
// skipActionOptsInit keeps root from building the control-plane
// connection (and probing the current org) up front: a LOCAL-mode
// repository must reach no control plane, so the connection is built
// lazily below, only when the storage mode is CONTROL_PLANE.
Annotations: map[string]string{
prePushHookAnnotation: trueString,
skipActionOptsInit: trueString,
},
RunE: func(cmd *cobra.Command, _ []string) error {
InitHookLogger()

// The hook runs at the repository root, so "." resolves the nearest
// .chainloop.yml the same way the push identity does.
mode, err := config.LoadTraceStorageFromYML(".")
if err != nil {
return err
}

if mode == config.TraceStorageLocal {
return action.HandlePrePushHookLocal(cmd.Context(), logger, Version)
}

// Control plane: build ActionOpts lazily, since this command is
// skipActionOptsInit and root did not.
if err := ensureActionOpts(cmd); err != nil {
return err
}

return action.HandlePrePushHook(cmd.Context(), logger, action.RunTracePushOpts{
ActionOpts: ActionOpts,
CLIVersion: Version,
Expand Down
Loading
Loading