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
39 changes: 39 additions & 0 deletions app/cli/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/rs/zerolog"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -141,3 +142,41 @@ func TestNewRootCmdInitializesLogger(t *testing.T) {
rootLogger.Error().Msg("boom")
assert.Contains(t, out.String(), "boom")
}

// A command flag with the name or the shorthand of a global flag hides the global flag on that
// command. For example, a local --org hides the global --org that selects the organization.
func TestNoCommandShadowsAGlobalFlag(t *testing.T) {
root := NewRootCmd(zerolog.Nop())

globalNames := map[string]bool{}
globalShorthands := map[string]bool{}
root.PersistentFlags().VisitAll(func(f *pflag.Flag) {
globalNames[f.Name] = true
if f.Shorthand != "" {
globalShorthands[f.Shorthand] = true
}
})

// Flags that hid a global flag before this test existed. Do not add to this list.
known := map[string]bool{
"chainloop artifact download --output": true,
"chainloop trace uninstall --yes": true,
"chainloop trace uninstall -y": true,
}

var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
for _, sub := range c.Commands() {
sub.LocalFlags().VisitAll(func(f *pflag.Flag) {
if name := sub.CommandPath() + " --" + f.Name; globalNames[f.Name] && !known[name] {
assert.Failf(t, "global flag hidden", "%q defines --%s, which hides the global flag", sub.CommandPath(), f.Name)
}
if name := sub.CommandPath() + " -" + f.Shorthand; f.Shorthand != "" && globalShorthands[f.Shorthand] && !known[name] {
assert.Failf(t, "global flag hidden", "%q defines -%s, which hides the global flag", sub.CommandPath(), f.Shorthand)
}
})
walk(sub)
}
}
walk(root)
}
21 changes: 19 additions & 2 deletions app/cli/cmd/workflow_contract_update.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2024-2025 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 @@ -24,10 +24,24 @@ import (
func newWorkflowContractUpdateCmd() *cobra.Command {
var name, description, contractPath string
var contractName string
var scope action.WorkflowContractUpdateScope

cmd := &cobra.Command{
Use: "update",
Short: "Update an existing contract",
Long: `Update an existing contract.

Use --project to move an organization-level contract to a project, or --org-level to move a
project-level contract to the organization. Only organization administrators and
organization-level API tokens can move a contract.`,
Example: ` # Update the contract content
chainloop workflow contract update --name my-contract --contract contract.yaml

# Move an organization-level contract to a project
chainloop workflow contract update --name my-contract --project my-project

# Move a project-level contract to the organization
chainloop workflow contract update --name my-contract --org-level`,
PreRunE: func(_ *cobra.Command, _ []string) error {
// Validate and extract the contract name
var err error
Expand All @@ -44,7 +58,7 @@ func newWorkflowContractUpdateCmd() *cobra.Command {
desc = &description
}

res, err := action.NewWorkflowContractUpdate(ActionOpts).Run(contractName, desc, contractPath)
res, err := action.NewWorkflowContractUpdate(ActionOpts).Run(contractName, desc, contractPath, scope)
if err != nil {
return err
}
Expand All @@ -57,6 +71,9 @@ func newWorkflowContractUpdateCmd() *cobra.Command {
cmd.Flags().StringVar(&name, "name", "", "contract name")
cmd.Flags().StringVarP(&contractPath, "contract", "f", "", "path or URL to the contract schema")
cmd.Flags().StringVar(&description, "description", "", "description of the contract")
cmd.Flags().StringVar(&scope.ProjectName, "project", "", "move the contract to this project, the contract must be at the organization level")
cmd.Flags().BoolVar(&scope.Organization, "org-level", false, "move the contract to the organization level")
cmd.MarkFlagsMutuallyExclusive("project", "org-level")

return cmd
}
23 changes: 23 additions & 0 deletions app/cli/documentation/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -3744,17 +3744,40 @@ Options inherited from parent commands

Update an existing contract

Synopsis

Update an existing contract.

Use --project to move an organization-level contract to a project, or --org-level to move a
project-level contract to the organization. Only organization administrators and
organization-level API tokens can move a contract.

```
chainloop workflow contract update [flags]
```

Examples

```
Update the contract content
chainloop workflow contract update --name my-contract --contract contract.yaml

Move an organization-level contract to a project
chainloop workflow contract update --name my-contract --project my-project

Move a project-level contract to the organization
chainloop workflow contract update --name my-contract --org-level
```

Options

```
-f, --contract string path or URL to the contract schema
--description string description of the contract
-h, --help help for update
--name string contract name
--org-level move the contract to the organization level
--project string move the contract to this project, the contract must be at the organization level
```

Options inherited from parent commands
Expand Down
31 changes: 29 additions & 2 deletions app/cli/pkg/action/workflow_contract_update.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,6 +17,7 @@ package action

import (
"context"
"errors"

pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
)
Expand All @@ -29,10 +30,36 @@ func NewWorkflowContractUpdate(cfg *ActionsOpts) *WorkflowContractUpdate {
return &WorkflowContractUpdate{cfg}
}

func (action *WorkflowContractUpdate) Run(name string, description *string, contractPath string) (*WorkflowContractWithVersionItem, error) {
// WorkflowContractUpdateScope is the level that an update converts the contract to.
// The zero value keeps the level of the contract.
type WorkflowContractUpdateScope struct {
// ProjectName converts the contract to a project-level contract of this project
ProjectName string
// Organization converts the contract to an organization-level contract
Organization bool
}

// apply sets the level of the update request
func (s WorkflowContractUpdateScope) apply(req *pb.WorkflowContractServiceUpdateRequest) error {
switch {
case s.ProjectName != "" && s.Organization:
return errors.New("only one of a project or the organization can be set")
case s.ProjectName != "":
req.Scope = &pb.WorkflowContractServiceUpdateRequest_ProjectReference{ProjectReference: &pb.IdentityReference{Name: &s.ProjectName}}
case s.Organization:
req.Scope = &pb.WorkflowContractServiceUpdateRequest_OrganizationScoped{OrganizationScoped: true}
}

return nil
}

func (action *WorkflowContractUpdate) Run(name string, description *string, contractPath string, scope WorkflowContractUpdateScope) (*WorkflowContractWithVersionItem, error) {
client := pb.NewWorkflowContractServiceClient(action.cfg.CPConnection)

request := &pb.WorkflowContractServiceUpdateRequest{Name: name, Description: description}
if err := scope.apply(request); err != nil {
return nil, err
}
if contractPath != "" {
rawContract, err := LoadFileOrURL(contractPath)
if err != nil {
Expand Down
74 changes: 74 additions & 0 deletions app/cli/pkg/action/workflow_contract_update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// Copyright 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.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package action

import (
"testing"

pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
)

func TestWorkflowContractUpdateScopeApply(t *testing.T) {
projectName := "contract-project"

testCases := []struct {
name string
scope WorkflowContractUpdateScope
want *pb.WorkflowContractServiceUpdateRequest
wantErr string
}{
{
name: "no scope keeps the level",
want: &pb.WorkflowContractServiceUpdateRequest{},
},
{
name: "to a project",
scope: WorkflowContractUpdateScope{ProjectName: projectName},
want: &pb.WorkflowContractServiceUpdateRequest{
Scope: &pb.WorkflowContractServiceUpdateRequest_ProjectReference{ProjectReference: &pb.IdentityReference{Name: &projectName}},
},
},
{
name: "to the organization",
scope: WorkflowContractUpdateScope{Organization: true},
want: &pb.WorkflowContractServiceUpdateRequest{
Scope: &pb.WorkflowContractServiceUpdateRequest_OrganizationScoped{OrganizationScoped: true},
},
},
{
name: "both",
scope: WorkflowContractUpdateScope{ProjectName: projectName, Organization: true},
wantErr: "only one",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := &pb.WorkflowContractServiceUpdateRequest{}
err := tc.scope.apply(req)
if tc.wantErr != "" {
assert.ErrorContains(t, err, tc.wantErr)
return
}

require.NoError(t, err)
assert.True(t, proto.Equal(tc.want, req), "got %v", req)
})
}
}
Loading
Loading