diff --git a/app/cli/cmd/workflow_contract.go b/app/cli/cmd/workflow_contract.go index 6d4d2a8c9..2bc0569ee 100644 --- a/app/cli/cmd/workflow_contract.go +++ b/app/cli/cmd/workflow_contract.go @@ -28,6 +28,7 @@ func newWorkflowContractCmd() *cobra.Command { cmd.AddCommand( newWorkflowContractListCmd(), newWorkflowContractCreateCmd(), + newWorkflowContractApplyCmd(), newWorkflowContractDescribeCmd(), newWorkflowContractUpdateCmd(), newWorkflowContractDeleteCmd(), diff --git a/app/cli/cmd/workflow_contract_apply.go b/app/cli/cmd/workflow_contract_apply.go new file mode 100644 index 000000000..98e95976d --- /dev/null +++ b/app/cli/cmd/workflow_contract_apply.go @@ -0,0 +1,59 @@ +// +// Copyright 2025 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 cmd + +import ( + "github.com/chainloop-dev/chainloop/app/cli/cmd/output" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + "github.com/spf13/cobra" +) + +func newWorkflowContractApplyCmd() *cobra.Command { + var contractPath, name, description, projectName string + + cmd := &cobra.Command{ + Use: "apply", + Short: "Apply a contract (create or update)", + Long: `Apply a contract from a file. This command will create the contract if it doesn't exist, +or update it if it already exists.`, + Example: ` # Apply a contract from file + chainloop workflow contract apply --contract my-contract.yaml --name my-contract --project my-project`, + RunE: func(cmd *cobra.Command, _ []string) error { + var desc *string + if cmd.Flags().Changed("description") { + desc = &description + } + + res, err := action.NewWorkflowContractApply(ActionOpts).Run(cmd.Context(), name, contractPath, desc, projectName) + if err != nil { + return err + } + + logger.Info().Msg("Contract applied!") + return output.EncodeOutput(flagOutputFormat, res, contractItemTableOutput) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "contract name") + err := cmd.MarkFlagRequired("name") + cobra.CheckErr(err) + + 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(&projectName, "project", "", "project name used to scope the contract, if not set the contract will be created in the organization") + + return cmd +} diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 3926bdf32..019073e82 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -3291,6 +3291,52 @@ Options inherited from parent commands -y, --yes Skip confirmation ``` +#### chainloop workflow contract apply + +Apply a contract (create or update) + +Synopsis + +Apply a contract from a file. This command will create the contract if it doesn't exist, +or update it if it already exists. + +``` +chainloop workflow contract apply [flags] +``` + +Examples + +``` +Apply a contract from file +chainloop workflow contract apply --contract my-contract.yaml --name my-contract --project my-project +``` + +Options + +``` +-f, --contract string path or URL to the contract schema +--description string description of the contract +-h, --help help for apply +--name string contract name +--project string project name used to scope the contract, if not set the contract will be created in the organization +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + #### chainloop workflow contract create Create a new contract diff --git a/app/cli/pkg/action/workflow_contract_apply.go b/app/cli/pkg/action/workflow_contract_apply.go new file mode 100644 index 000000000..63a6f9335 --- /dev/null +++ b/app/cli/pkg/action/workflow_contract_apply.go @@ -0,0 +1,87 @@ +// +// Copyright 2025 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 ( + "context" + "fmt" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" +) + +type WorkflowContractApply struct { + cfg *ActionsOpts +} + +func NewWorkflowContractApply(cfg *ActionsOpts) *WorkflowContractApply { + return &WorkflowContractApply{cfg} +} + +func (action *WorkflowContractApply) Run(ctx context.Context, contractName string, contractPath string, description *string, projectName string) (*WorkflowContractItem, error) { + client := pb.NewWorkflowContractServiceClient(action.cfg.CPConnection) + + // Try to describe the specific contract first to determine if we should create or update + describeReq := &pb.WorkflowContractServiceDescribeRequest{ + Name: contractName, + } + + var rawContract []byte + if contractPath != "" { + raw, err := LoadFileOrURL(contractPath) + if err != nil { + action.cfg.Logger.Debug().Err(err).Msg("loading the contract") + return nil, err + } + rawContract = raw + } + + _, err := client.Describe(ctx, describeReq) + if err == nil { + // Contract exists, perform update + updateReq := &pb.WorkflowContractServiceUpdateRequest{ + Name: contractName, + Description: description, + RawContract: rawContract, + } + + res, err := client.Update(ctx, updateReq) + if err != nil { + return nil, fmt.Errorf("failed to update existing contract '%s': %w", contractName, err) + } + + return pbWorkflowContractItemToAction(res.Result.Contract), nil + } + + // Contract doesn't exist, perform create + createReq := &pb.WorkflowContractServiceCreateRequest{ + Name: contractName, + Description: description, + RawContract: rawContract, + } + + if projectName != "" { + createReq.ProjectReference = &pb.IdentityReference{ + Name: &projectName, + } + } + + res, err := client.Create(ctx, createReq) + if err != nil { + return nil, fmt.Errorf("failed to create new contract '%s': %w", contractName, err) + } + + return pbWorkflowContractItemToAction(res.Result), nil +}