Skip to content

Commit db9eca6

Browse files
committed
apply command
Signed-off-by: Sylwester Piskozub <sylwesterpiskozub@gmail.com>
1 parent ba5b90c commit db9eca6

3 files changed

Lines changed: 301 additions & 1 deletion

File tree

app/cli/cmd/apply.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package cmd
17+
18+
import (
19+
"fmt"
20+
21+
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
22+
"github.com/spf13/cobra"
23+
)
24+
25+
func newApplyCmd() *cobra.Command {
26+
var filePath string
27+
28+
cmd := &cobra.Command{
29+
Use: "apply",
30+
Short: "Apply resources from YAML files",
31+
Long: `Apply resources from a YAML file or directory.
32+
Supports multi-document YAML files. Each document must have a 'kind' field.`,
33+
Example: ` # Apply resources from a single file
34+
chainloop apply -f my-contract.yaml
35+
36+
# Apply resources from a directory
37+
chainloop apply -f ./contracts/`,
38+
RunE: func(cmd *cobra.Command, args []string) error {
39+
results, err := action.NewApply(ActionOpts).Run(cmd.Context(), filePath)
40+
if err != nil {
41+
return err
42+
}
43+
44+
var contracts int
45+
var errors []string
46+
for _, r := range results {
47+
if r.Error != nil {
48+
errors = append(errors, fmt.Sprintf(" %s/%s: %s", r.Kind, r.Name, r.Error))
49+
continue
50+
}
51+
switch r.Kind {
52+
case action.KindContract:
53+
contracts++
54+
}
55+
}
56+
57+
if len(errors) > 0 {
58+
for _, e := range errors {
59+
logger.Error().Msg(e)
60+
}
61+
return fmt.Errorf("%d of %d resources failed to apply", len(errors), len(results))
62+
}
63+
64+
logger.Info().Msgf("%d contracts applied", contracts)
65+
66+
return nil
67+
},
68+
}
69+
70+
cmd.Flags().StringVarP(&filePath, "file", "f", "", "path to a YAML file or directory")
71+
cobra.CheckErr(cmd.MarkFlagRequired("file"))
72+
73+
return cmd
74+
}

app/cli/cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
265265
rootCmd.AddCommand(newWorkflowCmd(), newAuthCmd(), NewVersionCmd(),
266266
newAttestationCmd(), newArtifactCmd(), newConfigCmd(),
267267
newIntegrationCmd(), newOrganizationCmd(), newCASBackendCmd(),
268-
newReferrerDiscoverCmd(), newPolicyCmd(),
268+
newReferrerDiscoverCmd(), newPolicyCmd(), newApplyCmd(),
269269
)
270270

271271
// Load plugins for root command and subcommands (except completion and help)

app/cli/pkg/action/apply.go

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package action
17+
18+
import (
19+
"bytes"
20+
"context"
21+
"errors"
22+
"fmt"
23+
"io"
24+
"os"
25+
"path/filepath"
26+
"strings"
27+
28+
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
29+
"google.golang.org/grpc"
30+
"gopkg.in/yaml.v3"
31+
)
32+
33+
const (
34+
KindContract = "Contract"
35+
)
36+
37+
// ApplyResult holds the outcome of applying a single resource document
38+
type ApplyResult struct {
39+
Kind string
40+
Name string
41+
Error error
42+
}
43+
44+
// YAMLDoc holds a parsed YAML document with its kind and raw bytes
45+
type YAMLDoc struct {
46+
Kind string
47+
Name string
48+
RawData []byte
49+
}
50+
51+
// Apply handles applying resources from YAML files
52+
type Apply struct {
53+
cfg *ActionsOpts
54+
}
55+
56+
// NewApply creates a new Apply action
57+
func NewApply(cfg *ActionsOpts) *Apply {
58+
return &Apply{cfg: cfg}
59+
}
60+
61+
// Run applies all resources found in the given path (file or directory)
62+
func (a *Apply) Run(ctx context.Context, path string) ([]*ApplyResult, error) {
63+
docs, err := ParseYAMLPath(path)
64+
if err != nil {
65+
return nil, err
66+
}
67+
68+
// Apply contracts
69+
var results []*ApplyResult
70+
for _, doc := range docs {
71+
result := &ApplyResult{Kind: doc.Kind, Name: doc.Name}
72+
switch doc.Kind {
73+
case KindContract:
74+
if err := ApplyContractFromRawData(ctx, a.cfg.CPConnection, doc.Name, doc.RawData); err != nil {
75+
result.Error = err
76+
}
77+
default:
78+
result.Error = fmt.Errorf("unsupported kind %q", doc.Kind)
79+
}
80+
results = append(results, result)
81+
}
82+
83+
return results, nil
84+
}
85+
86+
// ParseYAMLPath collects all YAML files from a path (file or directory),
87+
// reads them, and splits multi-document files into individual YAMLDoc entries.
88+
func ParseYAMLPath(path string) ([]*YAMLDoc, error) {
89+
files, err := CollectYAMLFiles(path)
90+
if err != nil {
91+
return nil, err
92+
}
93+
94+
if len(files) == 0 {
95+
return nil, fmt.Errorf("no YAML files found in %q", path)
96+
}
97+
98+
var allDocs []*YAMLDoc
99+
for _, f := range files {
100+
rawData, err := os.ReadFile(f)
101+
if err != nil {
102+
return nil, fmt.Errorf("reading file %s: %w", f, err)
103+
}
104+
105+
docs, err := SplitYAMLDocuments(rawData)
106+
if err != nil {
107+
return nil, fmt.Errorf("parsing file %s: %w", f, err)
108+
}
109+
110+
allDocs = append(allDocs, docs...)
111+
}
112+
113+
return allDocs, nil
114+
}
115+
116+
// ApplyContractFromRawData applies a single contract document using the gRPC client.
117+
// It uses describe to check existence, then creates or updates accordingly.
118+
func ApplyContractFromRawData(ctx context.Context, conn *grpc.ClientConn, name string, rawData []byte) error {
119+
client := pb.NewWorkflowContractServiceClient(conn)
120+
121+
// Try to describe the contract to determine if we should create or update
122+
_, err := client.Describe(ctx, &pb.WorkflowContractServiceDescribeRequest{
123+
Name: name,
124+
})
125+
if err == nil {
126+
// Contract exists, perform update
127+
_, err := client.Update(ctx, &pb.WorkflowContractServiceUpdateRequest{
128+
Name: name,
129+
RawContract: rawData,
130+
})
131+
if err != nil {
132+
return fmt.Errorf("failed to update contract %q: %w", name, err)
133+
}
134+
return nil
135+
}
136+
137+
// Contract doesn't exist, perform create
138+
_, err = client.Create(ctx, &pb.WorkflowContractServiceCreateRequest{
139+
Name: name,
140+
RawContract: rawData,
141+
})
142+
if err != nil {
143+
return fmt.Errorf("failed to create contract %q: %w", name, err)
144+
}
145+
146+
return nil
147+
}
148+
149+
// CollectYAMLFiles returns YAML file paths from the given path.
150+
// If path is a file, it returns that file. If a directory, it walks recursively.
151+
func CollectYAMLFiles(path string) ([]string, error) {
152+
info, err := os.Stat(path)
153+
if err != nil {
154+
return nil, fmt.Errorf("accessing path %q: %w", path, err)
155+
}
156+
157+
if !info.IsDir() {
158+
return []string{path}, nil
159+
}
160+
161+
var files []string
162+
err = filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error {
163+
if err != nil {
164+
return err
165+
}
166+
if d.IsDir() {
167+
return nil
168+
}
169+
ext := strings.ToLower(filepath.Ext(p))
170+
if ext == ".yaml" || ext == ".yml" {
171+
files = append(files, p)
172+
}
173+
return nil
174+
})
175+
if err != nil {
176+
return nil, fmt.Errorf("walking directory %q: %w", path, err)
177+
}
178+
179+
return files, nil
180+
}
181+
182+
// SplitYAMLDocuments splits a potentially multi-document YAML file into individual documents,
183+
// extracting kind and name from each.
184+
func SplitYAMLDocuments(rawData []byte) ([]*YAMLDoc, error) {
185+
decoder := yaml.NewDecoder(bytes.NewReader(rawData))
186+
187+
var docs []*YAMLDoc
188+
for {
189+
var node yaml.Node
190+
if err := decoder.Decode(&node); err != nil {
191+
if errors.Is(err, io.EOF) {
192+
break
193+
}
194+
return nil, fmt.Errorf("decoding YAML document: %w", err)
195+
}
196+
197+
// Marshal node back to bytes for the per-resource apply
198+
docBytes, err := yaml.Marshal(&node)
199+
if err != nil {
200+
return nil, fmt.Errorf("marshalling YAML node: %w", err)
201+
}
202+
203+
// Extract kind and name via partial unmarshal
204+
var header struct {
205+
Kind string `yaml:"kind"`
206+
Metadata struct {
207+
Name string `yaml:"name"`
208+
} `yaml:"metadata"`
209+
}
210+
if err := yaml.Unmarshal(docBytes, &header); err != nil {
211+
return nil, fmt.Errorf("extracting document kind: %w", err)
212+
}
213+
214+
if header.Kind == "" {
215+
return nil, fmt.Errorf("missing 'kind' field in YAML document")
216+
}
217+
218+
docs = append(docs, &YAMLDoc{
219+
Kind: header.Kind,
220+
Name: header.Metadata.Name,
221+
RawData: docBytes,
222+
})
223+
}
224+
225+
return docs, nil
226+
}

0 commit comments

Comments
 (0)