Skip to content

Commit dec0d5b

Browse files
committed
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 <miguel@chainloop.dev>
1 parent 4479d73 commit dec0d5b

3 files changed

Lines changed: 106 additions & 3 deletions

File tree

app/cli/cmd/attestation.go

Lines changed: 45 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,16 @@ package cmd
1717

1818
import (
1919
"fmt"
20+
"os"
21+
"path/filepath"
2022
"strings"
2123

24+
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
25+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
26+
v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2227
"github.com/spf13/cobra"
28+
"github.com/spf13/viper"
29+
"google.golang.org/protobuf/encoding/protojson"
2330
)
2431

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

92+
// attestationStatePath returns the resolved path for local attestation state.
93+
// If customPath is non-empty it is returned as-is; otherwise the default
94+
// temp-dir location is used.
95+
func attestationStatePath(customPath string) string {
96+
if customPath != "" {
97+
return customPath
98+
}
99+
100+
return filepath.Join(os.TempDir(), action.DefaultAttestationStateFile)
101+
}
102+
103+
// orgFromLocalState reads the organization from the local attestation state file.
104+
// Returns empty string on any error (file not found, parse error, etc.).
105+
func orgFromLocalState(customPath string) string {
106+
raw, err := os.ReadFile(attestationStatePath(customPath))
107+
if err != nil {
108+
return ""
109+
}
110+
111+
state := &crafter.VersionedCraftingState{CraftingState: &v1.CraftingState{}}
112+
if err := protojson.Unmarshal(raw, state); err != nil {
113+
return ""
114+
}
115+
116+
return state.GetAttestation().GetWorkflow().GetOrganization()
117+
}
118+
75119
// extractAnnotations extracts the annotations from the flag and returns a map
76120
// the expected input format is key=value
77121
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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ 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

4346
type ActionsOpts struct {
@@ -83,7 +86,7 @@ func newCrafter(stateOpts *newCrafterStateOpts, conn *grpc.ClientConn, opts ...c
8386
case true:
8487
stateManager, err = remote.New(pb.NewAttestationStateServiceClient(conn), c.Logger)
8588
case false:
86-
attestationStatePath := filepath.Join(os.TempDir(), "chainloop-attestation.tmp.json")
89+
attestationStatePath := filepath.Join(os.TempDir(), DefaultAttestationStateFile)
8790
if path := stateOpts.localStatePath; path != "" {
8891
attestationStatePath = path
8992
}

0 commit comments

Comments
 (0)