Skip to content

Commit ee1162b

Browse files
authored
fix(cli): use org from local attestation state for subsequent commands (#2823)
Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent e6f9f07 commit ee1162b

3 files changed

Lines changed: 105 additions & 6 deletions

File tree

app/cli/cmd/attestation.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -17,9 +17,15 @@ package cmd
1717

1818
import (
1919
"fmt"
20+
"os"
2021
"strings"
2122

23+
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
24+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
25+
v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2226
"github.com/spf13/cobra"
27+
"github.com/spf13/viper"
28+
"google.golang.org/protobuf/encoding/protojson"
2329
)
2430

2531
var (
@@ -38,6 +44,16 @@ func newAttestationCmd() *cobra.Command {
3844
Short: "Craft Software Supply Chain Attestations",
3945
Example: "Refer to https://docs.chainloop.dev/getting-started/attestation-crafting",
4046
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
47+
// For non-init attestation subcommands using local state, load the org
48+
// from the crafting state so the gRPC connection uses the correct org header.
49+
// This handles the case where `init --org X` was used but subsequent commands
50+
// would otherwise fall back to the default org from the config file.
51+
if orgFlag := cmd.Root().PersistentFlags().Lookup(confOptions.organization.flagName); cmd.Name() != "init" && !useAttestationRemoteState && (orgFlag == nil || !orgFlag.Changed) {
52+
if org := orgFromLocalState(attestationLocalStatePath); org != "" {
53+
viper.Set(confOptions.organization.viperKey, org)
54+
}
55+
}
56+
4157
// run the initialization of the root command plus the new logic
4258
// specific to this attestation command
4359
rootCmd := cmd.Parent().Parent()
@@ -72,6 +88,22 @@ func flagAttestationID(cmd *cobra.Command) {
7288
cmd.Flags().StringVar(&attestationID, "attestation-id", "", "Unique identifier of the in-progress attestation")
7389
}
7490

91+
// orgFromLocalState reads the organization from the local attestation state file.
92+
// Returns empty string on any error (file not found, parse error, etc.).
93+
func orgFromLocalState(customPath string) string {
94+
raw, err := os.ReadFile(action.AttestationStatePath(customPath))
95+
if err != nil {
96+
return ""
97+
}
98+
99+
state := &crafter.VersionedCraftingState{CraftingState: &v1.CraftingState{}}
100+
if err := protojson.Unmarshal(raw, state); err != nil {
101+
return ""
102+
}
103+
104+
return state.GetAttestation().GetWorkflow().GetOrganization()
105+
}
106+
75107
// extractAnnotations extracts the annotations from the flag and returns a map
76108
// the expected input format is key=value
77109
func extractAnnotations(annotationsFlag []string) (map[string]string, error) {

app/cli/cmd/attestation_test.go

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2023 The Chainloop Authors.
2+
// Copyright 2023-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -16,11 +16,67 @@
1616
package cmd
1717

1818
import (
19+
"os"
20+
"path/filepath"
1921
"testing"
2022

23+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
24+
v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2125
"github.com/stretchr/testify/assert"
26+
"github.com/stretchr/testify/require"
27+
"google.golang.org/protobuf/encoding/protojson"
2228
)
2329

30+
func TestOrgFromLocalState(t *testing.T) {
31+
t.Run("returns org from valid state file", func(t *testing.T) {
32+
state := &crafter.VersionedCraftingState{
33+
CraftingState: &v1.CraftingState{
34+
Attestation: &v1.Attestation{
35+
Workflow: &v1.WorkflowMetadata{
36+
Organization: "my-org",
37+
},
38+
},
39+
},
40+
}
41+
42+
raw, err := protojson.Marshal(state)
43+
require.NoError(t, err)
44+
45+
statePath := filepath.Join(t.TempDir(), "state.json")
46+
require.NoError(t, os.WriteFile(statePath, raw, 0o600))
47+
48+
assert.Equal(t, "my-org", orgFromLocalState(statePath))
49+
})
50+
51+
t.Run("returns empty for missing file", func(t *testing.T) {
52+
assert.Empty(t, orgFromLocalState(filepath.Join(t.TempDir(), "nonexistent.json")))
53+
})
54+
55+
t.Run("returns empty for invalid json", func(t *testing.T) {
56+
statePath := filepath.Join(t.TempDir(), "bad.json")
57+
require.NoError(t, os.WriteFile(statePath, []byte("not json"), 0o600))
58+
assert.Empty(t, orgFromLocalState(statePath))
59+
})
60+
61+
t.Run("returns empty when org not set in state", func(t *testing.T) {
62+
state := &crafter.VersionedCraftingState{
63+
CraftingState: &v1.CraftingState{
64+
Attestation: &v1.Attestation{
65+
Workflow: &v1.WorkflowMetadata{},
66+
},
67+
},
68+
}
69+
70+
raw, err := protojson.Marshal(state)
71+
require.NoError(t, err)
72+
73+
statePath := filepath.Join(t.TempDir(), "state.json")
74+
require.NoError(t, os.WriteFile(statePath, raw, 0o600))
75+
76+
assert.Empty(t, orgFromLocalState(statePath))
77+
})
78+
}
79+
2480
func TestExtractAnnotations(t *testing.T) {
2581
testCases := []struct {
2682
input []string

app/cli/pkg/action/action.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,22 @@ import (
3838
const (
3939
PolicyViolationBlockingStrategyEnforced = "ENFORCED"
4040
PolicyViolationBlockingStrategyAdvisory = "ADVISORY"
41+
42+
// defaultAttestationStateFile is the default file name for local attestation state.
43+
defaultAttestationStateFile = "chainloop-attestation.tmp.json"
4144
)
4245

46+
// AttestationStatePath returns the resolved path for local attestation state.
47+
// If customPath is non-empty it is returned as-is; otherwise the default
48+
// temp-dir location is used.
49+
func AttestationStatePath(customPath string) string {
50+
if customPath != "" {
51+
return customPath
52+
}
53+
54+
return filepath.Join(os.TempDir(), defaultAttestationStateFile)
55+
}
56+
4357
type ActionsOpts struct {
4458
CPConnection *grpc.ClientConn
4559
Logger zerolog.Logger
@@ -83,10 +97,7 @@ func newCrafter(stateOpts *newCrafterStateOpts, conn *grpc.ClientConn, opts ...c
8397
case true:
8498
stateManager, err = remote.New(pb.NewAttestationStateServiceClient(conn), c.Logger)
8599
case false:
86-
attestationStatePath := filepath.Join(os.TempDir(), "chainloop-attestation.tmp.json")
87-
if path := stateOpts.localStatePath; path != "" {
88-
attestationStatePath = path
89-
}
100+
attestationStatePath := AttestationStatePath(stateOpts.localStatePath)
90101

91102
c.Logger.Debug().Str("path", fmt.Sprintf("file:%s", attestationStatePath)).Msg("using local state")
92103
stateManager, err = filesystem.New(attestationStatePath)

0 commit comments

Comments
 (0)