diff --git a/app/cli/cmd/attestation.go b/app/cli/cmd/attestation.go index 118e5e6a8..e78815ea5 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,15 @@ package cmd import ( "fmt" + "os" "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 +44,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 +88,22 @@ func flagAttestationID(cmd *cobra.Command) { cmd.Flags().StringVar(&attestationID, "attestation-id", "", "Unique identifier of the in-progress attestation") } +// 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(action.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..3250b91f4 100644 --- a/app/cli/pkg/action/action.go +++ b/app/cli/pkg/action/action.go @@ -38,8 +38,22 @@ import ( const ( PolicyViolationBlockingStrategyEnforced = "ENFORCED" PolicyViolationBlockingStrategyAdvisory = "ADVISORY" + + // 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 @@ -83,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(), "chainloop-attestation.tmp.json") - 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)