From dec0d5bd8ece9f7a292b5ead4516dd0aee7f8bdd Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 6 Mar 2026 23:51:03 +0100 Subject: [PATCH 1/2] fix(cli): use org from local attestation state for subsequent commands When `att init --org X` is used, subsequent subcommands (add, push, etc.) now read the organization from the local crafting state file so the gRPC connection targets the correct org instead of falling back to the default from the CLI config. Fixes #2822 Signed-off-by: Miguel Martinez Trivino --- app/cli/cmd/attestation.go | 46 +++++++++++++++++++++++++- app/cli/cmd/attestation_test.go | 58 ++++++++++++++++++++++++++++++++- app/cli/pkg/action/action.go | 5 ++- 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/app/cli/cmd/attestation.go b/app/cli/cmd/attestation.go index 118e5e6a8..5c40a789e 100644 --- a/app/cli/cmd/attestation.go +++ b/app/cli/cmd/attestation.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -17,9 +17,16 @@ package cmd import ( "fmt" + "os" + "path/filepath" "strings" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter" + v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/spf13/cobra" + "github.com/spf13/viper" + "google.golang.org/protobuf/encoding/protojson" ) var ( @@ -38,6 +45,16 @@ func newAttestationCmd() *cobra.Command { Short: "Craft Software Supply Chain Attestations", Example: "Refer to https://docs.chainloop.dev/getting-started/attestation-crafting", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // For non-init attestation subcommands using local state, load the org + // from the crafting state so the gRPC connection uses the correct org header. + // This handles the case where `init --org X` was used but subsequent commands + // would otherwise fall back to the default org from the config file. + if orgFlag := cmd.Root().PersistentFlags().Lookup(confOptions.organization.flagName); cmd.Name() != "init" && !useAttestationRemoteState && (orgFlag == nil || !orgFlag.Changed) { + if org := orgFromLocalState(attestationLocalStatePath); org != "" { + viper.Set(confOptions.organization.viperKey, org) + } + } + // run the initialization of the root command plus the new logic // specific to this attestation command rootCmd := cmd.Parent().Parent() @@ -72,6 +89,33 @@ func flagAttestationID(cmd *cobra.Command) { cmd.Flags().StringVar(&attestationID, "attestation-id", "", "Unique identifier of the in-progress attestation") } +// attestationStatePath returns the resolved path for local attestation state. +// If customPath is non-empty it is returned as-is; otherwise the default +// temp-dir location is used. +func attestationStatePath(customPath string) string { + if customPath != "" { + return customPath + } + + return filepath.Join(os.TempDir(), action.DefaultAttestationStateFile) +} + +// orgFromLocalState reads the organization from the local attestation state file. +// Returns empty string on any error (file not found, parse error, etc.). +func orgFromLocalState(customPath string) string { + raw, err := os.ReadFile(attestationStatePath(customPath)) + if err != nil { + return "" + } + + state := &crafter.VersionedCraftingState{CraftingState: &v1.CraftingState{}} + if err := protojson.Unmarshal(raw, state); err != nil { + return "" + } + + return state.GetAttestation().GetWorkflow().GetOrganization() +} + // extractAnnotations extracts the annotations from the flag and returns a map // the expected input format is key=value func extractAnnotations(annotationsFlag []string) (map[string]string, error) { diff --git a/app/cli/cmd/attestation_test.go b/app/cli/cmd/attestation_test.go index 883ba7b29..48562b462 100644 --- a/app/cli/cmd/attestation_test.go +++ b/app/cli/cmd/attestation_test.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,11 +16,67 @@ package cmd import ( + "os" + "path/filepath" "testing" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter" + v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" ) +func TestOrgFromLocalState(t *testing.T) { + t.Run("returns org from valid state file", func(t *testing.T) { + state := &crafter.VersionedCraftingState{ + CraftingState: &v1.CraftingState{ + Attestation: &v1.Attestation{ + Workflow: &v1.WorkflowMetadata{ + Organization: "my-org", + }, + }, + }, + } + + raw, err := protojson.Marshal(state) + require.NoError(t, err) + + statePath := filepath.Join(t.TempDir(), "state.json") + require.NoError(t, os.WriteFile(statePath, raw, 0o600)) + + assert.Equal(t, "my-org", orgFromLocalState(statePath)) + }) + + t.Run("returns empty for missing file", func(t *testing.T) { + assert.Empty(t, orgFromLocalState(filepath.Join(t.TempDir(), "nonexistent.json"))) + }) + + t.Run("returns empty for invalid json", func(t *testing.T) { + statePath := filepath.Join(t.TempDir(), "bad.json") + require.NoError(t, os.WriteFile(statePath, []byte("not json"), 0o600)) + assert.Empty(t, orgFromLocalState(statePath)) + }) + + t.Run("returns empty when org not set in state", func(t *testing.T) { + state := &crafter.VersionedCraftingState{ + CraftingState: &v1.CraftingState{ + Attestation: &v1.Attestation{ + Workflow: &v1.WorkflowMetadata{}, + }, + }, + } + + raw, err := protojson.Marshal(state) + require.NoError(t, err) + + statePath := filepath.Join(t.TempDir(), "state.json") + require.NoError(t, os.WriteFile(statePath, raw, 0o600)) + + assert.Empty(t, orgFromLocalState(statePath)) + }) +} + func TestExtractAnnotations(t *testing.T) { testCases := []struct { input []string diff --git a/app/cli/pkg/action/action.go b/app/cli/pkg/action/action.go index bdca71999..eb7906517 100644 --- a/app/cli/pkg/action/action.go +++ b/app/cli/pkg/action/action.go @@ -38,6 +38,9 @@ import ( const ( PolicyViolationBlockingStrategyEnforced = "ENFORCED" PolicyViolationBlockingStrategyAdvisory = "ADVISORY" + + // DefaultAttestationStateFile is the default file name for local attestation state. + DefaultAttestationStateFile = "chainloop-attestation.tmp.json" ) type ActionsOpts struct { @@ -83,7 +86,7 @@ func newCrafter(stateOpts *newCrafterStateOpts, conn *grpc.ClientConn, opts ...c case true: stateManager, err = remote.New(pb.NewAttestationStateServiceClient(conn), c.Logger) case false: - attestationStatePath := filepath.Join(os.TempDir(), "chainloop-attestation.tmp.json") + attestationStatePath := filepath.Join(os.TempDir(), DefaultAttestationStateFile) if path := stateOpts.localStatePath; path != "" { attestationStatePath = path } From ca07ced197565f04f0afb8ba63494130362418e4 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Fri, 6 Mar 2026 23:56:40 +0100 Subject: [PATCH 2/2] fix(cli): move AttestationStatePath helper to action package Deduplicate the attestation state path logic by having both newCrafter and orgFromLocalState use the shared action.AttestationStatePath helper. Signed-off-by: Miguel Martinez Trivino --- app/cli/cmd/attestation.go | 14 +------------- app/cli/pkg/action/action.go | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/app/cli/cmd/attestation.go b/app/cli/cmd/attestation.go index 5c40a789e..e78815ea5 100644 --- a/app/cli/cmd/attestation.go +++ b/app/cli/cmd/attestation.go @@ -18,7 +18,6 @@ package cmd import ( "fmt" "os" - "path/filepath" "strings" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" @@ -89,21 +88,10 @@ func flagAttestationID(cmd *cobra.Command) { cmd.Flags().StringVar(&attestationID, "attestation-id", "", "Unique identifier of the in-progress attestation") } -// attestationStatePath returns the resolved path for local attestation state. -// If customPath is non-empty it is returned as-is; otherwise the default -// temp-dir location is used. -func attestationStatePath(customPath string) string { - if customPath != "" { - return customPath - } - - return filepath.Join(os.TempDir(), action.DefaultAttestationStateFile) -} - // orgFromLocalState reads the organization from the local attestation state file. // Returns empty string on any error (file not found, parse error, etc.). func orgFromLocalState(customPath string) string { - raw, err := os.ReadFile(attestationStatePath(customPath)) + raw, err := os.ReadFile(action.AttestationStatePath(customPath)) if err != nil { return "" } diff --git a/app/cli/pkg/action/action.go b/app/cli/pkg/action/action.go index eb7906517..3250b91f4 100644 --- a/app/cli/pkg/action/action.go +++ b/app/cli/pkg/action/action.go @@ -39,10 +39,21 @@ const ( PolicyViolationBlockingStrategyEnforced = "ENFORCED" PolicyViolationBlockingStrategyAdvisory = "ADVISORY" - // DefaultAttestationStateFile is the default file name for local attestation state. - DefaultAttestationStateFile = "chainloop-attestation.tmp.json" + // defaultAttestationStateFile is the default file name for local attestation state. + defaultAttestationStateFile = "chainloop-attestation.tmp.json" ) +// AttestationStatePath returns the resolved path for local attestation state. +// If customPath is non-empty it is returned as-is; otherwise the default +// temp-dir location is used. +func AttestationStatePath(customPath string) string { + if customPath != "" { + return customPath + } + + return filepath.Join(os.TempDir(), defaultAttestationStateFile) +} + type ActionsOpts struct { CPConnection *grpc.ClientConn Logger zerolog.Logger @@ -86,10 +97,7 @@ func newCrafter(stateOpts *newCrafterStateOpts, conn *grpc.ClientConn, opts ...c case true: stateManager, err = remote.New(pb.NewAttestationStateServiceClient(conn), c.Logger) case false: - attestationStatePath := filepath.Join(os.TempDir(), DefaultAttestationStateFile) - if path := stateOpts.localStatePath; path != "" { - attestationStatePath = path - } + attestationStatePath := AttestationStatePath(stateOpts.localStatePath) c.Logger.Debug().Str("path", fmt.Sprintf("file:%s", attestationStatePath)).Msg("using local state") stateManager, err = filesystem.New(attestationStatePath)