diff --git a/cmd/root.go b/cmd/root.go index 33346a65d..9481d16f1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ import ( "os" "path/filepath" + "github.com/kitops-ml/kitops/pkg/cmd/attest" "github.com/kitops-ml/kitops/pkg/cmd/dev" "github.com/kitops-ml/kitops/pkg/cmd/diff" "github.com/kitops-ml/kitops/pkg/cmd/info" @@ -37,8 +38,10 @@ import ( "github.com/kitops-ml/kitops/pkg/cmd/pull" "github.com/kitops-ml/kitops/pkg/cmd/push" "github.com/kitops-ml/kitops/pkg/cmd/remove" + "github.com/kitops-ml/kitops/pkg/cmd/sign" "github.com/kitops-ml/kitops/pkg/cmd/tag" "github.com/kitops-ml/kitops/pkg/cmd/unpack" + "github.com/kitops-ml/kitops/pkg/cmd/verify" "github.com/kitops-ml/kitops/pkg/cmd/version" "github.com/kitops-ml/kitops/pkg/lib/constants" "github.com/kitops-ml/kitops/pkg/lib/filesystem/cache" @@ -167,6 +170,9 @@ func addSubcommands(rootCmd *cobra.Command) { rootCmd.AddCommand(diff.DiffCommand()) rootCmd.AddCommand(kitimport.ImportCommand()) rootCmd.AddCommand(kitcache.CacheCommand()) + rootCmd.AddCommand(sign.SignCommand()) + rootCmd.AddCommand(attest.AttestCommand()) + rootCmd.AddCommand(verify.VerifyCommand()) } // Execute adds all child commands to the root command and sets flags appropriately. diff --git a/pkg/cmd/attest/attest.go b/pkg/cmd/attest/attest.go new file mode 100644 index 000000000..18aa2fc26 --- /dev/null +++ b/pkg/cmd/attest/attest.go @@ -0,0 +1,43 @@ +// Copyright 2025 The KitOps 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package attest + +import ( + "context" + "fmt" + "os" + "os/exec" +) + +func RunAttest(context context.Context, options *attestOptions) error { + _, err := exec.LookPath("cosign") + if err != nil { + fmt.Println() + return fmt.Errorf("cosign not found, please install cosign") + } + cmd := exec.CommandContext(context, "cosign", options.cosignArgs...) + + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + + err = cmd.Run() + if err != nil { + return fmt.Errorf("attestation failed: %w", err) + } + return nil +} diff --git a/pkg/cmd/attest/cmd.go b/pkg/cmd/attest/cmd.go new file mode 100644 index 000000000..0cbca849c --- /dev/null +++ b/pkg/cmd/attest/cmd.go @@ -0,0 +1,81 @@ +// Copyright 2025 The KitOps 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package attest + +import ( + "context" + "fmt" + + "github.com/kitops-ml/kitops/pkg/lib/completion" + "github.com/kitops-ml/kitops/pkg/lib/constants" + "github.com/kitops-ml/kitops/pkg/output" + "github.com/spf13/cobra" +) + +const ( + shortDesc = "Attest the supplied container image. Use the same flags as cosign." + example = `kit attest --predicate PREDICATE_FILE --key cosign.key --tlog-upload=false IMAGE_URI` +) + +type attestOptions struct { + configHome string + cosignArgs []string +} + +func (opts *attestOptions) complete(ctx context.Context, args []string) error { + configHome, ok := ctx.Value(constants.ConfigKey{}).(string) + if !ok { + return fmt.Errorf("default config path not set on command context") + } + opts.configHome = configHome + opts.cosignArgs = args + return nil +} + +func AttestCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "attest [flags]", + Short: shortDesc, + Example: example, + RunE: runCommand(&attestOptions{}), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return completion.GetLocalModelKitsCompletion(cmd.Context(), toComplete), cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + }, + DisableFlagParsing: true, + } + + return cmd +} + +func runCommand(opts *attestOptions) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + args = append([]string{"attest"}, args...) + if err := opts.complete(cmd.Context(), args); err != nil { + return output.Fatalf("Invalid arguments: %w", err) + } + + err := RunAttest(cmd.Context(), opts) + if err != nil { + return output.Fatalf("Failed to attest: %w", err) + } + output.Infof("Attestation successful") + return nil + } +} diff --git a/pkg/cmd/sign/cmd.go b/pkg/cmd/sign/cmd.go new file mode 100644 index 000000000..ba87c2fe2 --- /dev/null +++ b/pkg/cmd/sign/cmd.go @@ -0,0 +1,82 @@ +// Copyright 2025 The KitOps 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package sign + +import ( + "context" + "fmt" + + "github.com/kitops-ml/kitops/pkg/lib/completion" + "github.com/kitops-ml/kitops/pkg/lib/constants" + "github.com/kitops-ml/kitops/pkg/output" + "github.com/spf13/cobra" +) + +const ( + shortDesc = "Sign the supplied container image. Use the same flags as cosign." + example = `kit sign --key cosign.key --tlog-upload=false myimage:latest` +) + +type signOptions struct { + configHome string + cosignArgs []string +} + +func SignCommand() *cobra.Command { + + cmd := &cobra.Command{ + Use: "sign [flags]", + Short: shortDesc, + Example: example, + RunE: runCommand(&signOptions{}), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return completion.GetLocalModelKitsCompletion(cmd.Context(), toComplete), cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + }, + DisableFlagParsing: true, + } + + return cmd +} + +func (opts *signOptions) complete(ctx context.Context, args []string) error { + configHome, ok := ctx.Value(constants.ConfigKey{}).(string) + if !ok { + return fmt.Errorf("default config path not set on command context") + } + opts.configHome = configHome + opts.cosignArgs = args + return nil +} + +func runCommand(opts *signOptions) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + args = append([]string{"sign"}, args...) + if err := opts.complete(cmd.Context(), args); err != nil { + return output.Fatalf("Invalid arguments: %w", err) + } + + err := RunSign(cmd.Context(), opts) + if err != nil { + return output.Fatalf("Failed to sign: %w", err) + } + output.Infof("Modelkit signed") + return nil + } +} diff --git a/pkg/cmd/sign/sign.go b/pkg/cmd/sign/sign.go new file mode 100644 index 000000000..4c3f63589 --- /dev/null +++ b/pkg/cmd/sign/sign.go @@ -0,0 +1,44 @@ +// Copyright 2025 The KitOps 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package sign + +import ( + "context" + "fmt" + "os" + "os/exec" +) + +func RunSign(ctx context.Context, options *signOptions) error { + _, err := exec.LookPath("cosign") + if err != nil { + fmt.Println() + return fmt.Errorf("cosign not found, please install cosign") + } + + cmd := exec.CommandContext(ctx, "cosign", options.cosignArgs...) + + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + + err = cmd.Run() + if err != nil { + return fmt.Errorf("signing failed: %w", err) + } + return nil +} diff --git a/pkg/cmd/verify/cmd.go b/pkg/cmd/verify/cmd.go new file mode 100644 index 000000000..ab10988b5 --- /dev/null +++ b/pkg/cmd/verify/cmd.go @@ -0,0 +1,104 @@ +// Copyright 2025 The KitOps 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package verify + +import ( + "context" + "fmt" + "strings" + + "github.com/kitops-ml/kitops/pkg/lib/completion" + "github.com/kitops-ml/kitops/pkg/lib/constants" + "github.com/kitops-ml/kitops/pkg/output" + "github.com/spf13/cobra" +) + +const ( + shortDesc = "Verify the ModelKit signature and attestation. Runs both verify and verify-attestation. Use --verify.* and --verify-attestation.* for flags specific to each step." + example = `kit verify --key cosign.pub --verify.insecure-ignore-tlog=true DIGEST` +) + +type verifyOptions struct { + configHome string + cosignArgs []string +} + +func (opts *verifyOptions) complete(ctx context.Context, args []string) error { + configHome, ok := ctx.Value(constants.ConfigKey{}).(string) + if !ok { + return fmt.Errorf("default config path not set on command context") + } + opts.configHome = configHome + opts.cosignArgs = args + + return nil +} + +func VerifyCommand() *cobra.Command { + + cmd := &cobra.Command{ + Use: "verify [flags]", + Short: shortDesc, + Example: example, + RunE: runCommand(), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return completion.GetLocalModelKitsCompletion(cmd.Context(), toComplete), cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + }, + DisableFlagParsing: true, + } + + return cmd +} + +func runCommand() func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + commands := []string{"verify", "verify-attestation"} + argsnew := [][]string{{}, {}} + for _, val := range args { + if val, ok := strings.CutPrefix(val, "--verify."); ok { + argsnew[0] = append(argsnew[0], "--"+val) + } else if val, ok := strings.CutPrefix(val, "--verify-attestation."); ok { + argsnew[1] = append(argsnew[1], "--"+val) + } else { + argsnew[0] = append(argsnew[0], val) + argsnew[1] = append(argsnew[1], val) + } + } + + opts := make([]verifyOptions, 2) + + for i := range 2 { + opts = append(opts, verifyOptions{}) + argsnew[i] = append([]string{commands[i]}, argsnew[i]...) + if err := opts[i].complete(cmd.Context(), argsnew[i]); err != nil { + return output.Fatalf("Invalid arguments: %w", err) + } + } + + for i := range len(opts) { + err := RunVerify(cmd.Context(), opts[i]) + if err != nil { + return output.Fatalf("Failed to %s: %w", commands[i], err) + } + } + output.Infof("Verification successful") + return nil + } +} diff --git a/pkg/cmd/verify/verify.go b/pkg/cmd/verify/verify.go new file mode 100644 index 000000000..aa29ade8b --- /dev/null +++ b/pkg/cmd/verify/verify.go @@ -0,0 +1,43 @@ +// Copyright 2025 The KitOps 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package verify + +import ( + "context" + "fmt" + "os" + "os/exec" +) + +func RunVerify(context context.Context, options verifyOptions) error { + _, err := exec.LookPath("cosign") + if err != nil { + fmt.Println() + return fmt.Errorf("cosign not found, please install cosign") + } + cmd := exec.CommandContext(context, "cosign", options.cosignArgs...) + + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + + err = cmd.Run() + if err != nil { + return fmt.Errorf("%s failed: %w", options.cosignArgs[0], err) + } + return nil +}