Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 33 additions & 1 deletion app/cli/cmd/attestation.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 (
Expand All @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down
58 changes: 57 additions & 1 deletion app/cli/cmd/attestation_test.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down
19 changes: 15 additions & 4 deletions app/cli/pkg/action/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading