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
52 changes: 52 additions & 0 deletions app/cli/cmd/apply.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// 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 cmd

import (
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
"github.com/spf13/cobra"
)

func newApplyCmd() *cobra.Command {
var filePath string

cmd := &cobra.Command{
Use: "apply",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since OSS only supports declarative workflow contracts, I think this command should be placed in chainloop wf contract apply.

Copy link
Collaborator Author

@Piskoo Piskoo Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have wf contract apply, this one is there to expose generic apply for all resources in chainloop, it will be extended in CLI EE. We can refactor wf contract apply in a separate task so it uses the new endpoint, although if we do that, we will lose the capability scoping contracts per project. We don't support projects in the schema

Short: "Apply resources from YAML files",
Long: `Apply resources from a YAML file or directory.
Supports multi-document YAML files. Each document must have a 'kind' field.`,
Example: ` # Apply resources from a single file
chainloop apply -f my-contract.yaml

# Apply resources from a directory
chainloop apply -f ./contracts/`,
RunE: func(cmd *cobra.Command, _ []string) error {
results, err := action.NewApply(ActionOpts).Run(cmd.Context(), filePath)
if err != nil {
return err
}

logger.Info().Msgf("%d contracts applied", len(results))

return nil
},
}

cmd.Flags().StringVarP(&filePath, "file", "f", "", "path to a YAML file or directory")
cobra.CheckErr(cmd.MarkFlagRequired("file"))

return cmd
}
2 changes: 1 addition & 1 deletion app/cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
rootCmd.AddCommand(newWorkflowCmd(), newAuthCmd(), NewVersionCmd(),
newAttestationCmd(), newArtifactCmd(), newConfigCmd(),
newIntegrationCmd(), newOrganizationCmd(), newCASBackendCmd(),
newReferrerDiscoverCmd(), newPolicyCmd(),
newReferrerDiscoverCmd(), newPolicyCmd(), newApplyCmd(),
)

// Load plugins for root command and subcommands (except completion and help)
Expand Down
46 changes: 46 additions & 0 deletions app/cli/documentation/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,52 @@ and vulnerability reports-directly from their CI/CD workflows, ensuring complian
The CLI operates through a contract-based workflow. Security teams define workflow contracts specifying which types of evidence must be collected and how they should be validated. Developers then use the Chainloop CLI to initialize
an attestation, add the required materials, and submit the attestation for validation and storage.

## chainloop apply

Apply resources from YAML files

Synopsis

Apply resources from a YAML file or directory.
Supports multi-document YAML files. Each document must have a 'kind' field.

```
chainloop apply [flags]
```

Examples

```
Apply resources from a single file
chainloop apply -f my-contract.yaml

Apply resources from a directory
chainloop apply -f ./contracts/
```

Options

```
-f, --file string path to a YAML file or directory
-h, --help help for apply
```

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 artifact

Download or upload Artifacts to the CAS
Expand Down
210 changes: 210 additions & 0 deletions app/cli/pkg/action/apply.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
//
// 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 (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"

pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
"google.golang.org/grpc"
"gopkg.in/yaml.v3"
)

const (
KindContract = "Contract"
)

// ApplyResult holds the outcome of a successfully applied resource document
type ApplyResult struct {
Kind string
Name string
}

// YAMLDoc holds a parsed YAML document with its kind and raw bytes
type YAMLDoc struct {
Kind string
Name string
RawData []byte
}

// Apply handles applying resources from YAML files
type Apply struct {
cfg *ActionsOpts
}

// NewApply creates a new Apply action
func NewApply(cfg *ActionsOpts) *Apply {
return &Apply{cfg: cfg}
}

// Run applies all resources found in the given path (file or directory)
func (a *Apply) Run(ctx context.Context, path string) ([]*ApplyResult, error) {
docs, err := ParseYAMLPath(path)
if err != nil {
return nil, err
}

// Apply contracts
var results []*ApplyResult
for _, doc := range docs {
result := &ApplyResult{Kind: doc.Kind, Name: doc.Name}
switch doc.Kind {
case KindContract:
if err := ApplyContractFromRawData(ctx, a.cfg.CPConnection, doc.RawData); err != nil {
return results, fmt.Errorf("%s/%s: %w", doc.Kind, doc.Name, err)
}
default:
return results, fmt.Errorf("unsupported kind %q", doc.Kind)
}
results = append(results, result)
}

return results, nil
}

// ParseYAMLPath collects all YAML files from a path (file or directory),
// reads them, and splits multi-document files into individual YAMLDoc entries.
func ParseYAMLPath(path string) ([]*YAMLDoc, error) {
files, err := CollectYAMLFiles(path)
if err != nil {
return nil, err
}

if len(files) == 0 {
return nil, fmt.Errorf("no YAML files found in %q", path)
}

var allDocs []*YAMLDoc
for _, f := range files {
rawData, err := os.ReadFile(f)
if err != nil {
return nil, fmt.Errorf("reading file %s: %w", f, err)
}

docs, err := SplitYAMLDocuments(rawData)
if err != nil {
return nil, fmt.Errorf("parsing file %s: %w", f, err)
}

allDocs = append(allDocs, docs...)
}

return allDocs, nil
}

// ApplyContractFromRawData applies a single contract document using the gRPC client.
func ApplyContractFromRawData(ctx context.Context, conn *grpc.ClientConn, rawData []byte) error {
client := pb.NewWorkflowContractServiceClient(conn)

_, err := client.Apply(ctx, &pb.WorkflowContractServiceApplyRequest{
RawSchema: rawData,
})
if err != nil {
return fmt.Errorf("failed to apply contract: %w", err)
}

return nil
}

// CollectYAMLFiles returns YAML file paths from the given path.
// If path is a file, it returns that file. If a directory, it walks recursively.
func CollectYAMLFiles(path string) ([]string, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("accessing path %q: %w", path, err)
}

if !info.IsDir() {
return []string{path}, nil
}

var files []string
err = filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(p))
if ext == ".yaml" || ext == ".yml" {
files = append(files, p)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walking directory %q: %w", path, err)
}

return files, nil
}

// SplitYAMLDocuments splits a potentially multi-document YAML file into individual documents,
// extracting kind and name from each.
func SplitYAMLDocuments(rawData []byte) ([]*YAMLDoc, error) {
decoder := yaml.NewDecoder(bytes.NewReader(rawData))

var docs []*YAMLDoc
for {
var node yaml.Node
if err := decoder.Decode(&node); err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, fmt.Errorf("decoding YAML document: %w", err)
}

// Marshal node back to bytes for the per-resource apply
docBytes, err := yaml.Marshal(&node)
if err != nil {
return nil, fmt.Errorf("marshalling YAML node: %w", err)
}

// Extract kind and name via partial unmarshal
var header struct {
Kind string `yaml:"kind"`
Metadata struct {
Name string `yaml:"name"`
} `yaml:"metadata"`
}
if err := yaml.Unmarshal(docBytes, &header); err != nil {
return nil, fmt.Errorf("extracting document kind: %w", err)
}

if header.Kind == "" {
return nil, fmt.Errorf("missing 'kind' field in YAML document")
}

if header.Metadata.Name == "" {
return nil, fmt.Errorf("missing 'metadata.name' field in YAML document of kind %q", header.Kind)
}

docs = append(docs, &YAMLDoc{
Kind: header.Kind,
Name: header.Metadata.Name,
RawData: docBytes,
})
}

return docs, nil
}
Loading
Loading