mirror of
https://github.com/kubevela/kubevela.git
synced 2026-08-18 03:56:36 +00:00
The remote URL of a Terraform module can embed credentials (for example https://user:token@host/repo.git). The cache-reuse logic logged the raw URL and wrote it to the .remote-url cache marker, which could leak those credentials into controller logs and onto disk. Strip the userinfo from the URL before logging it and before writing the cache marker. The marker now stores a credential-free URL and the reuse check compares the same stripped form, so cache reuse and re-clone-on-change behave as before while no secret is persisted. Signed-off-by: Ayush Kumar <65535504+roguepikachu@users.noreply.github.com>
932 lines
37 KiB
Go
932 lines
37 KiB
Go
/*
|
|
Copyright 2021 The KubeVela 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 utils
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/getkin/kin-openapi/openapi3"
|
|
"github.com/go-git/go-git/v5"
|
|
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
|
"github.com/pkg/errors"
|
|
v1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
k8stypes "k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/klog/v2"
|
|
"k8s.io/utils/ptr"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
|
|
commontypes "github.com/oam-dev/kubevela/apis/core.oam.dev/common"
|
|
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
|
|
"github.com/oam-dev/kubevela/apis/types"
|
|
"github.com/oam-dev/kubevela/pkg/appfile"
|
|
"github.com/oam-dev/kubevela/pkg/oam/util"
|
|
"github.com/oam-dev/kubevela/pkg/schema"
|
|
"github.com/oam-dev/kubevela/pkg/utils/common"
|
|
"github.com/oam-dev/kubevela/pkg/utils/terraform"
|
|
)
|
|
|
|
// data types of parameter value
|
|
const (
|
|
TerraformVariableString string = "string"
|
|
TerraformVariableNumber string = "number"
|
|
TerraformVariableBool string = "bool"
|
|
TerraformVariableList string = "list"
|
|
TerraformVariableTuple string = "tuple"
|
|
TerraformVariableMap string = "map"
|
|
TerraformVariableObject string = "object"
|
|
TerraformVariableNull string = ""
|
|
TerraformVariableAny string = "any"
|
|
|
|
TerraformListTypePrefix string = "list("
|
|
TerraformTupleTypePrefix string = "tuple("
|
|
TerraformMapTypePrefix string = "map("
|
|
TerraformObjectTypePrefix string = "object("
|
|
TerraformSetTypePrefix string = "set("
|
|
|
|
typeTraitDefinition = "trait"
|
|
typeComponentDefinition = "component"
|
|
typeWorkflowStepDefinition = "workflowstep"
|
|
typePolicyStepDefinition = "policy"
|
|
)
|
|
|
|
const (
|
|
// GitCredsKnownHosts is a key in git credentials secret
|
|
GitCredsKnownHosts string = "known_hosts"
|
|
)
|
|
|
|
// ErrNoSectionParameterInCue means there is not parameter section in Cue template of a workload
|
|
type ErrNoSectionParameterInCue struct {
|
|
capName string
|
|
}
|
|
|
|
func (e ErrNoSectionParameterInCue) Error() string {
|
|
return fmt.Sprintf("capability %s doesn't contain section `parameter`", e.capName)
|
|
}
|
|
|
|
// CapabilityDefinitionInterface is the interface for Capability (WorkloadDefinition and TraitDefinition)
|
|
type CapabilityDefinitionInterface interface {
|
|
GetCapabilityObject(ctx context.Context, k8sClient client.Client, namespace, name string) (*types.Capability, error)
|
|
GetOpenAPISchema(ctx context.Context, k8sClient client.Client, namespace, name string) ([]byte, error)
|
|
}
|
|
|
|
// CapabilityComponentDefinition is the struct for ComponentDefinition
|
|
type CapabilityComponentDefinition struct {
|
|
Name string `json:"name"`
|
|
ComponentDefinition v1beta1.ComponentDefinition `json:"componentDefinition"`
|
|
|
|
WorkloadType util.WorkloadType `json:"workloadType"`
|
|
WorkloadDefName string `json:"workloadDefName"`
|
|
|
|
Terraform *commontypes.Terraform `json:"terraform"`
|
|
CapabilityBaseDefinition
|
|
}
|
|
|
|
// NewCapabilityComponentDef will create a CapabilityComponentDefinition
|
|
func NewCapabilityComponentDef(componentDefinition *v1beta1.ComponentDefinition) CapabilityComponentDefinition {
|
|
var def CapabilityComponentDefinition
|
|
def.Name = componentDefinition.Name
|
|
if componentDefinition.Spec.Workload.Definition == (commontypes.WorkloadGVK{}) && componentDefinition.Spec.Workload.Type != "" {
|
|
def.WorkloadType = util.ReferWorkload
|
|
def.WorkloadDefName = componentDefinition.Spec.Workload.Type
|
|
}
|
|
if componentDefinition.Spec.Schematic != nil {
|
|
if componentDefinition.Spec.Schematic.Terraform != nil {
|
|
def.WorkloadType = util.TerraformDef
|
|
def.Terraform = componentDefinition.Spec.Schematic.Terraform
|
|
}
|
|
}
|
|
def.ComponentDefinition = *componentDefinition.DeepCopy()
|
|
return def
|
|
}
|
|
|
|
// GetOpenAPISchema gets OpenAPI v3 schema by WorkloadDefinition name
|
|
func (def *CapabilityComponentDefinition) GetOpenAPISchema(ctx context.Context, name string) ([]byte, error) {
|
|
capability, err := appfile.ConvertTemplateJSON2Object(name, def.ComponentDefinition.Spec.Extension, def.ComponentDefinition.Spec.Schematic)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to convert ComponentDefinition to Capability Object")
|
|
}
|
|
return getOpenAPISchema(ctx, capability)
|
|
}
|
|
|
|
// GetOpenAPISchemaFromTerraformComponentDefinition gets OpenAPI v3 schema by WorkloadDefinition name
|
|
func GetOpenAPISchemaFromTerraformComponentDefinition(configuration string) ([]byte, error) {
|
|
schemas := make(map[string]*openapi3.Schema)
|
|
var required []string
|
|
variables, _, err := common.ParseTerraformVariables(configuration)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to generate capability properties")
|
|
}
|
|
for k, v := range variables {
|
|
var schema *openapi3.Schema
|
|
switch v.Type {
|
|
case TerraformVariableString:
|
|
schema = openapi3.NewStringSchema()
|
|
case TerraformVariableNumber:
|
|
schema = openapi3.NewFloat64Schema()
|
|
case TerraformVariableBool:
|
|
schema = openapi3.NewBoolSchema()
|
|
case TerraformVariableList, TerraformVariableTuple:
|
|
schema = openapi3.NewArraySchema()
|
|
case TerraformVariableMap, TerraformVariableObject:
|
|
schema = openapi3.NewObjectSchema()
|
|
case TerraformVariableAny:
|
|
switch v.Default.(type) {
|
|
case []interface{}:
|
|
schema = openapi3.NewArraySchema()
|
|
case map[string]interface{}:
|
|
schema = openapi3.NewObjectSchema()
|
|
}
|
|
case TerraformVariableNull:
|
|
switch v.Default.(type) {
|
|
case nil, string:
|
|
schema = openapi3.NewStringSchema()
|
|
case []interface{}:
|
|
schema = openapi3.NewArraySchema()
|
|
case map[string]interface{}:
|
|
schema = openapi3.NewObjectSchema()
|
|
case int, float64:
|
|
schema = openapi3.NewFloat64Schema()
|
|
default:
|
|
return nil, fmt.Errorf("null type variable is NOT supported, please specify a type for the variable: %s", v.Name)
|
|
}
|
|
}
|
|
|
|
// To identify unusual list type
|
|
if schema == nil {
|
|
switch {
|
|
case strings.HasPrefix(v.Type, TerraformListTypePrefix) || strings.HasPrefix(v.Type, TerraformTupleTypePrefix) ||
|
|
strings.HasPrefix(v.Type, TerraformSetTypePrefix):
|
|
schema = openapi3.NewArraySchema()
|
|
case strings.HasPrefix(v.Type, TerraformMapTypePrefix) || strings.HasPrefix(v.Type, TerraformObjectTypePrefix):
|
|
schema = openapi3.NewObjectSchema()
|
|
default:
|
|
return nil, fmt.Errorf("the type `%s` of variable %s is NOT supported", v.Type, v.Name)
|
|
}
|
|
}
|
|
schema.Title = k
|
|
if v.Required {
|
|
required = append(required, k)
|
|
}
|
|
if v.Default != nil {
|
|
schema.Default = v.Default
|
|
}
|
|
schema.Description = v.Description
|
|
schemas[v.Name] = schema
|
|
}
|
|
|
|
otherProperties := parseOtherProperties4TerraformDefinition()
|
|
for k, v := range otherProperties {
|
|
schemas[k] = v
|
|
}
|
|
|
|
return generateJSONSchemaWithRequiredProperty(schemas, required)
|
|
}
|
|
|
|
// GetTerraformConfigurationFromRemote gets Terraform Configuration(HCL)
|
|
func GetTerraformConfigurationFromRemote(name, remoteURL, remotePath string, sshPublicKey *gitssh.PublicKeys) (string, error) {
|
|
if err := validateModuleName(name); err != nil {
|
|
return "", err
|
|
}
|
|
userHome, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
cachePath := filepath.Join(userHome, ".vela", "terraform", name)
|
|
|
|
// Reuse the cache only when it is populated AND was cloned from the same URL.
|
|
// Otherwise (empty, a changed URL, or a missing marker) re-clone, so a
|
|
// definition repointed at a different repository does not keep serving the old
|
|
// tree. See GHSA-fmgp-q6jx-gg3x.
|
|
if !cacheMatchesRemote(cachePath, remoteURL) {
|
|
_ = os.RemoveAll(cachePath)
|
|
klog.InfoS("cloning remote Terraform module", "module", name, "url", redactURLCredentials(remoteURL), "cache", cachePath)
|
|
if err = cloneTerraformModule(cachePath, remoteURL, sshPublicKey); err != nil {
|
|
// Do not leave a partial or oversized clone behind for the next reconcile.
|
|
klog.ErrorS(err, "failed to clone remote Terraform module", "module", name, "url", redactURLCredentials(remoteURL))
|
|
_ = os.RemoveAll(cachePath)
|
|
return "", err
|
|
}
|
|
recordCacheRemote(cachePath, remoteURL)
|
|
}
|
|
sshKnownHostsPath := os.Getenv("SSH_KNOWN_HOSTS")
|
|
_ = os.Remove(sshKnownHostsPath)
|
|
|
|
conf, err := readTerraformConfigFromDir(cachePath, remotePath)
|
|
if err != nil {
|
|
// Drop the cached clone on any rejection or read failure so a corrected or
|
|
// replaced repository is re-cloned next time, rather than the controller
|
|
// re-reading a poisoned or stale tree forever. Wiping on a transient read
|
|
// error only costs one bounded re-clone, which beats risking a stuck poison.
|
|
klog.InfoS("evicting Terraform module cache after a failed read", "module", name, "cache", cachePath, "err", err.Error())
|
|
_ = os.RemoveAll(cachePath)
|
|
return "", err
|
|
}
|
|
return conf, nil
|
|
}
|
|
|
|
// cacheRemoteMarkerSuffix names the sibling file that records the remote a cached
|
|
// Terraform module was cloned from, used to detect a changed remote. A
|
|
// credential-stripped form of the URL is stored (see redactURLCredentials) so the
|
|
// marker never persists secrets embedded in an authenticated Git URL.
|
|
const cacheRemoteMarkerSuffix = ".remote-url"
|
|
|
|
// validateModuleName rejects names that could steer the cache path (and the
|
|
// os.RemoveAll that cleans it) outside the module cache directory. Callers pass a
|
|
// Kubernetes object name, but the check keeps that guarantee local and explicit.
|
|
func validateModuleName(name string) error {
|
|
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
|
|
return errors.Errorf("invalid Terraform module name %q", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// cacheMatchesRemote reports whether cachePath holds a populated clone recorded
|
|
// as coming from remoteURL. The marker stores a credential-stripped URL, so the
|
|
// comparison strips credentials from remoteURL the same way before matching.
|
|
func cacheMatchesRemote(cachePath, remoteURL string) bool {
|
|
entities, err := os.ReadDir(cachePath)
|
|
if err != nil || len(entities) == 0 {
|
|
return false
|
|
}
|
|
recorded, err := os.ReadFile(filepath.Clean(cachePath + cacheRemoteMarkerSuffix))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return string(recorded) == redactURLCredentials(remoteURL)
|
|
}
|
|
|
|
// recordCacheRemote records the remote a freshly cloned module came from. It stores a
|
|
// credential-stripped URL so the marker never persists secrets that an authenticated
|
|
// Git URL may embed.
|
|
func recordCacheRemote(cachePath, remoteURL string) {
|
|
if err := os.WriteFile(cachePath+cacheRemoteMarkerSuffix, []byte(redactURLCredentials(remoteURL)), 0600); err != nil {
|
|
klog.ErrorS(err, "failed to record Terraform module remote URL", "cache", cachePath)
|
|
}
|
|
}
|
|
|
|
// redactURLCredentials strips any embedded userinfo (a username, password, or token)
|
|
// from a URL so it is safe to log and persist. Authenticated HTTPS Git URLs can carry
|
|
// credentials in the userinfo; scp-style SSH URLs ("git@host:path") fail url.Parse but
|
|
// authenticate with keys, so they carry no secret. The same stripped form is used for
|
|
// logging and for the cache marker, so record and match stay consistent.
|
|
func redactURLCredentials(raw string) string {
|
|
if u, err := url.Parse(raw); err == nil {
|
|
u.User = nil
|
|
return u.String()
|
|
}
|
|
// url.Parse failed (typically scp-style SSH). Strip a leading "user@" defensively
|
|
// in case a malformed URL still embeds credentials before the host.
|
|
if _, after, found := strings.Cut(raw, "@"); found {
|
|
return after
|
|
}
|
|
return raw
|
|
}
|
|
|
|
const (
|
|
// cloneTimeout bounds the network fetch of a remote Terraform module clone, so a
|
|
// slow or oversized attacker-supplied repository cannot hang the controller. It
|
|
// covers the git transport only; the worktree checkout that follows is not bound
|
|
// by this deadline.
|
|
cloneTimeout = 2 * time.Minute
|
|
// maxCloneBytes and maxCloneEntries cap the on-disk footprint of a cloned remote
|
|
// Terraform module (modules are small). They run AFTER the clone completes, so
|
|
// they bound the RETAINED clone (an oversized clone is rejected and removed by
|
|
// the caller) rather than peak transient disk during transfer/checkout; Depth:1
|
|
// keeps that transient footprint to a single shallow commit. Fully bounding peak
|
|
// transfer disk (NoCheckout plus object-store reads, or an instrumented
|
|
// filesystem) is a follow-up. See GHSA-fmgp-q6jx-gg3x.
|
|
maxCloneBytes int64 = 64 << 20 // 64 MiB
|
|
// maxCloneEntries counts every filesystem entry (files, directories, and
|
|
// symlinks), so a tree of many empty directories or symlinks cannot bypass the
|
|
// bound and exhaust inodes.
|
|
maxCloneEntries = 50000
|
|
)
|
|
|
|
// cloneTerraformModule shallow-clones remoteURL into cachePath under a fetch
|
|
// timeout, then rejects an oversized clone. Depth:1 drops history, the timeout
|
|
// bounds the network fetch, and the post-clone caps reject a clone whose retained
|
|
// size or entry count is too large (the caller removes the rejected clone).
|
|
func cloneTerraformModule(cachePath, remoteURL string, sshPublicKey *gitssh.PublicKeys) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), cloneTimeout)
|
|
defer cancel()
|
|
|
|
cloneOptions := &git.CloneOptions{
|
|
URL: remoteURL,
|
|
Progress: os.Stdout,
|
|
Depth: 1,
|
|
}
|
|
if sshPublicKey != nil {
|
|
cloneOptions.Auth = sshPublicKey
|
|
}
|
|
if _, err := git.PlainCloneContext(ctx, cachePath, false, cloneOptions); err != nil {
|
|
return errors.Wrap(err, "failed to clone remote Terraform configuration")
|
|
}
|
|
return ensureDirWithinLimits(cachePath, maxCloneBytes, maxCloneEntries)
|
|
}
|
|
|
|
// ensureDirWithinLimits returns an error as soon as the entries under root exceed
|
|
// maxEntries in count or the regular files exceed maxBytes in total size. Every
|
|
// entry (regular files, directories, and symlinks) counts toward maxEntries, so a
|
|
// tree of many directories or symlinks cannot slip past the bound and exhaust
|
|
// inodes; only regular files contribute to the byte total. It counts everything
|
|
// under root (including .git, by design) and does not follow symlinks. The caps
|
|
// bound the retained clone, not peak disk during the clone (see maxCloneBytes).
|
|
func ensureDirWithinLimits(root string, maxBytes int64, maxEntries int) error {
|
|
var (
|
|
total int64
|
|
entries int
|
|
)
|
|
return filepath.WalkDir(root, func(_ string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries++
|
|
if entries > maxEntries {
|
|
return errors.Errorf("remote Terraform repository exceeds the maximum allowed entry count of %d", maxEntries)
|
|
}
|
|
if !d.Type().IsRegular() {
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
total += info.Size()
|
|
if total > maxBytes {
|
|
return errors.Errorf("remote Terraform repository exceeds the maximum allowed size of %d bytes", maxBytes)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// maxTerraformConfigBytes caps how much of a remote Terraform configuration
|
|
// file the loader will read. HCL configurations are small, so the cap stops a
|
|
// malicious or compromised repository from steering the read at an unbounded
|
|
// source (for example a variables.tf symlinked to /dev/zero) and OOM-killing
|
|
// the controller. See GHSA-fmgp-q6jx-gg3x.
|
|
const maxTerraformConfigBytes int64 = 1 << 20 // 1 MiB
|
|
|
|
// errTerraformConfigTooLarge builds the error returned when a candidate
|
|
// configuration file exceeds maxTerraformConfigBytes.
|
|
func errTerraformConfigTooLarge(path string, maxSize int64) error {
|
|
return errors.Errorf("refusing to read Terraform configuration %q: exceeds the maximum allowed size of %d bytes", path, maxSize)
|
|
}
|
|
|
|
// readTerraformConfigFromDir reads variables.tf, or main.tf as a fallback, from
|
|
// the cloned remote repository at cacheRoot/remotePath. It refuses any candidate
|
|
// that is not a regular file contained within cacheRoot (defeating symlink and
|
|
// path-traversal escapes) and any file larger than maxTerraformConfigBytes.
|
|
func readTerraformConfigFromDir(cacheRoot, remotePath string) (string, error) {
|
|
baseDir := filepath.Join(cacheRoot, remotePath)
|
|
for _, name := range []string{"variables.tf", "main.tf"} {
|
|
content, found, err := readContainedRegularFile(cacheRoot, filepath.Join(baseDir, name), maxTerraformConfigBytes)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if found {
|
|
return content, nil
|
|
}
|
|
}
|
|
return "", errors.New("failed to find main.tf or variables.tf in Terraform configurations of the remote repository")
|
|
}
|
|
|
|
// readContainedRegularFile reads path when it is a regular file whose real
|
|
// location stays inside root and whose size is within maxSize. found is false
|
|
// only when path does not exist, so callers can try a fallback name; any other
|
|
// problem (a symlink or traversal escape, an irregular file, or an oversize
|
|
// file) is a hard error rather than a silent fallback.
|
|
func readContainedRegularFile(root, path string, maxSize int64) (content string, found bool, err error) {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", false, nil
|
|
}
|
|
return "", false, errors.Wrap(err, "failed to stat Terraform configuration")
|
|
}
|
|
// Reject any symlink at the target outright, even one that resolves inside
|
|
// the cache. This is intentionally stricter than the containment check
|
|
// below: variables.tf/main.tf are expected to be regular files, so a blanket
|
|
// rejection is the simplest defense against the GHSA-fmgp-q6jx-gg3x
|
|
// symlink-to-/dev/zero vector. The containment check still guards a symlinked
|
|
// parent directory whose leaf is itself a regular file.
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return "", true, errors.Errorf("refusing to read symlinked Terraform configuration %q", path)
|
|
}
|
|
|
|
realRoot, err := filepath.EvalSymlinks(root)
|
|
if err != nil {
|
|
return "", true, errors.Wrap(err, "failed to resolve Terraform cache directory")
|
|
}
|
|
realPath, err := filepath.EvalSymlinks(path)
|
|
if err != nil {
|
|
return "", true, errors.Wrap(err, "failed to resolve Terraform configuration path")
|
|
}
|
|
if realPath != realRoot && !strings.HasPrefix(realPath, realRoot+string(os.PathSeparator)) {
|
|
return "", true, errors.Errorf("refusing to read Terraform configuration outside the cache directory: %q", path)
|
|
}
|
|
|
|
if !info.Mode().IsRegular() {
|
|
return "", true, errors.Errorf("refusing to read non-regular Terraform configuration %q", path)
|
|
}
|
|
if info.Size() > maxSize {
|
|
return "", true, errTerraformConfigTooLarge(path, maxSize)
|
|
}
|
|
|
|
f, err := os.Open(filepath.Clean(path))
|
|
if err != nil {
|
|
return "", true, errors.Wrap(err, "failed to read Terraform configuration")
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
buf, err := io.ReadAll(io.LimitReader(f, maxSize+1))
|
|
if err != nil {
|
|
return "", true, errors.Wrap(err, "failed to read Terraform configuration")
|
|
}
|
|
if int64(len(buf)) > maxSize {
|
|
return "", true, errTerraformConfigTooLarge(path, maxSize)
|
|
}
|
|
return string(buf), true, nil
|
|
}
|
|
|
|
func parseOtherProperties4TerraformDefinition() map[string]*openapi3.Schema {
|
|
otherProperties := make(map[string]*openapi3.Schema)
|
|
|
|
// 1. writeConnectionSecretToRef
|
|
secretName := openapi3.NewStringSchema()
|
|
secretName.Title = "name"
|
|
secretName.Description = terraform.TerraformSecretNameDescription
|
|
|
|
secretNamespace := openapi3.NewStringSchema()
|
|
secretNamespace.Title = "namespace"
|
|
secretNamespace.Description = terraform.TerraformSecretNamespaceDescription
|
|
|
|
secret := openapi3.NewObjectSchema()
|
|
secret.Title = terraform.TerraformWriteConnectionSecretToRefName
|
|
secret.Description = terraform.TerraformWriteConnectionSecretToRefDescription
|
|
secret.Properties = openapi3.Schemas{
|
|
"name": &openapi3.SchemaRef{Value: secretName},
|
|
"namespace": &openapi3.SchemaRef{Value: secretNamespace},
|
|
}
|
|
secret.Required = []string{"name"}
|
|
|
|
otherProperties[terraform.TerraformWriteConnectionSecretToRefName] = secret
|
|
|
|
// 2. providerRef
|
|
providerName := openapi3.NewStringSchema()
|
|
providerName.Title = "name"
|
|
providerName.Description = "The name of the Terraform Cloud provider"
|
|
|
|
providerNamespace := openapi3.NewStringSchema()
|
|
providerNamespace.Title = "namespace"
|
|
providerNamespace.Default = "default"
|
|
providerNamespace.Description = "The namespace of the Terraform Cloud provider"
|
|
|
|
var providerRefName = "providerRef"
|
|
provider := openapi3.NewObjectSchema()
|
|
provider.Title = providerRefName
|
|
provider.Description = "specifies the Provider"
|
|
provider.Properties = openapi3.Schemas{
|
|
"name": &openapi3.SchemaRef{Value: providerName},
|
|
"namespace": &openapi3.SchemaRef{Value: providerNamespace},
|
|
}
|
|
provider.Required = []string{"name"}
|
|
|
|
otherProperties[providerRefName] = provider
|
|
|
|
// 3. deleteResource
|
|
var deleteResourceName = "deleteResource"
|
|
deleteResource := openapi3.NewBoolSchema()
|
|
deleteResource.Title = deleteResourceName
|
|
deleteResource.Description = "DeleteResource will determine whether provisioned cloud resources will be deleted when application is deleted"
|
|
deleteResource.Default = true
|
|
otherProperties[deleteResourceName] = deleteResource
|
|
|
|
// 4. region
|
|
var regionName = "region"
|
|
region := openapi3.NewStringSchema()
|
|
region.Title = regionName
|
|
region.Description = "Region is cloud provider's region. It will override providerRef"
|
|
otherProperties[regionName] = region
|
|
|
|
return otherProperties
|
|
|
|
}
|
|
|
|
func generateJSONSchemaWithRequiredProperty(schemas map[string]*openapi3.Schema, required []string) ([]byte, error) {
|
|
s := openapi3.NewObjectSchema().WithProperties(schemas)
|
|
if len(required) > 0 {
|
|
s.Required = required
|
|
}
|
|
b, err := s.MarshalJSON()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "cannot marshal generated schema into json")
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// GetGitSSHPublicKey gets a kubernetes secret containing the SSH private key based on gitCredentialsSecretReference parameters for component and trait definition
|
|
func GetGitSSHPublicKey(ctx context.Context, k8sClient client.Client, gitCredentialsSecretReference *v1.SecretReference) (*gitssh.PublicKeys, error) {
|
|
gitCredentialsSecretName := gitCredentialsSecretReference.Name
|
|
gitCredentialsSecretNamespace := gitCredentialsSecretReference.Namespace
|
|
gitCredentialsNamespacedName := k8stypes.NamespacedName{Namespace: gitCredentialsSecretNamespace, Name: gitCredentialsSecretName}
|
|
|
|
secret := &v1.Secret{}
|
|
err := k8sClient.Get(ctx, gitCredentialsNamespacedName, secret)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get git credentials secret: %w", err)
|
|
}
|
|
needSecretKeys := []string{GitCredsKnownHosts, v1.SSHAuthPrivateKey}
|
|
for _, key := range needSecretKeys {
|
|
if _, ok := secret.Data[key]; !ok {
|
|
err := errors.Errorf("'%s' not in git credentials secret", key)
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
klog.InfoS("Reconcile gitCredentialsSecretReference", "gitCredentialsSecretReference", klog.KRef(gitCredentialsSecretNamespace, gitCredentialsSecretName))
|
|
|
|
sshPrivateKey := secret.Data[v1.SSHAuthPrivateKey]
|
|
publicKey, err := gitssh.NewPublicKeys("git", sshPrivateKey, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate public key from private key: %w", err)
|
|
}
|
|
sshKnownHosts := secret.Data[GitCredsKnownHosts]
|
|
sshDir := filepath.Join(os.TempDir(), ".ssh")
|
|
sshKnownHostsPath := filepath.Join(sshDir, GitCredsKnownHosts)
|
|
_ = os.Mkdir(sshDir, 0700)
|
|
err = os.WriteFile(sshKnownHostsPath, sshKnownHosts, 0600)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to write known hosts into file: %w", err)
|
|
}
|
|
_ = os.Setenv("SSH_KNOWN_HOSTS", sshKnownHostsPath)
|
|
return publicKey, nil
|
|
}
|
|
|
|
// StoreOpenAPISchema stores OpenAPI v3 schema in ConfigMap from WorkloadDefinition
|
|
func (def *CapabilityComponentDefinition) StoreOpenAPISchema(ctx context.Context, k8sClient client.Client, namespace, name, revName string) (string, error) {
|
|
var jsonSchema []byte
|
|
var err error
|
|
switch def.WorkloadType {
|
|
case util.TerraformDef:
|
|
if def.Terraform == nil {
|
|
return "", fmt.Errorf("no Configuration is set in Terraform specification: %s", def.Name)
|
|
}
|
|
configuration := def.Terraform.Configuration
|
|
if def.Terraform.Type == "remote" {
|
|
var publicKey *gitssh.PublicKeys
|
|
publicKey = nil
|
|
if def.Terraform.GitCredentialsSecretReference != nil {
|
|
gitCredentialsSecretReference := def.Terraform.GitCredentialsSecretReference
|
|
publicKey, err = GetGitSSHPublicKey(ctx, k8sClient, gitCredentialsSecretReference)
|
|
if err != nil {
|
|
return "", fmt.Errorf("issue with gitCredentialsSecretReference %s/%s: %w", gitCredentialsSecretReference.Namespace, gitCredentialsSecretReference.Name, err)
|
|
}
|
|
}
|
|
configuration, err = GetTerraformConfigurationFromRemote(def.Name, def.Terraform.Configuration, def.Terraform.Path, publicKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot get Terraform configuration %s from remote: %w", def.Name, err)
|
|
}
|
|
}
|
|
jsonSchema, err = GetOpenAPISchemaFromTerraformComponentDefinition(configuration)
|
|
default:
|
|
jsonSchema, err = def.GetOpenAPISchema(ctx, name)
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate OpenAPI v3 JSON schema for capability %s: %w", def.Name, err)
|
|
}
|
|
componentDefinition := def.ComponentDefinition
|
|
ownerReference := []metav1.OwnerReference{{
|
|
APIVersion: componentDefinition.APIVersion,
|
|
Kind: componentDefinition.Kind,
|
|
Name: componentDefinition.Name,
|
|
UID: componentDefinition.GetUID(),
|
|
Controller: ptr.To(true),
|
|
BlockOwnerDeletion: ptr.To(true),
|
|
}}
|
|
cmName, err := def.CreateOrUpdateConfigMap(ctx, k8sClient, namespace, componentDefinition.Name, typeComponentDefinition, componentDefinition.Labels, nil, jsonSchema, ownerReference)
|
|
if err != nil {
|
|
return cmName, err
|
|
}
|
|
|
|
// Create a configmap to store parameter for each definitionRevision
|
|
defRev := new(v1beta1.DefinitionRevision)
|
|
if err = k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: revName}, defRev); err != nil {
|
|
return "", err
|
|
}
|
|
ownerReference = []metav1.OwnerReference{{
|
|
APIVersion: defRev.APIVersion,
|
|
Kind: defRev.Kind,
|
|
Name: defRev.Name,
|
|
UID: defRev.GetUID(),
|
|
Controller: ptr.To(true),
|
|
BlockOwnerDeletion: ptr.To(true),
|
|
}}
|
|
_, err = def.CreateOrUpdateConfigMap(ctx, k8sClient, namespace, revName, typeComponentDefinition, defRev.Spec.ComponentDefinition.Labels, nil, jsonSchema, ownerReference)
|
|
if err != nil {
|
|
return cmName, err
|
|
}
|
|
return cmName, nil
|
|
}
|
|
|
|
// CapabilityTraitDefinition is the Capability struct for TraitDefinition
|
|
type CapabilityTraitDefinition struct {
|
|
Name string `json:"name"`
|
|
TraitDefinition v1beta1.TraitDefinition `json:"traitDefinition"`
|
|
|
|
DefCategoryType util.WorkloadType `json:"defCategoryType"`
|
|
|
|
CapabilityBaseDefinition
|
|
}
|
|
|
|
// NewCapabilityTraitDef will create a CapabilityTraitDefinition
|
|
func NewCapabilityTraitDef(traitdefinition *v1beta1.TraitDefinition) CapabilityTraitDefinition {
|
|
var def CapabilityTraitDefinition
|
|
def.Name = traitdefinition.Name // or def.Name = req.NamespacedName.Name
|
|
def.TraitDefinition = *traitdefinition.DeepCopy()
|
|
return def
|
|
}
|
|
|
|
// GetOpenAPISchema gets OpenAPI v3 schema by TraitDefinition name
|
|
func (def *CapabilityTraitDefinition) GetOpenAPISchema(ctx context.Context, name string) ([]byte, error) {
|
|
capability, err := appfile.ConvertTemplateJSON2Object(name, def.TraitDefinition.Spec.Extension, def.TraitDefinition.Spec.Schematic)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to convert WorkloadDefinition to Capability Object")
|
|
}
|
|
return getOpenAPISchema(ctx, capability)
|
|
}
|
|
|
|
// StoreOpenAPISchema stores OpenAPI v3 schema from TraitDefinition in ConfigMap
|
|
func (def *CapabilityTraitDefinition) StoreOpenAPISchema(ctx context.Context, k8sClient client.Client, namespace, name string, revName string) (string, error) {
|
|
jsonSchema, err := def.GetOpenAPISchema(ctx, name)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate OpenAPI v3 JSON schema for capability %s: %w", def.Name, err)
|
|
}
|
|
|
|
traitDefinition := def.TraitDefinition
|
|
ownerReference := []metav1.OwnerReference{{
|
|
APIVersion: traitDefinition.APIVersion,
|
|
Kind: traitDefinition.Kind,
|
|
Name: traitDefinition.Name,
|
|
UID: traitDefinition.GetUID(),
|
|
Controller: ptr.To(true),
|
|
BlockOwnerDeletion: ptr.To(true),
|
|
}}
|
|
cmName, err := def.CreateOrUpdateConfigMap(ctx, k8sClient, namespace, traitDefinition.Name, typeTraitDefinition, traitDefinition.Labels, traitDefinition.Spec.AppliesToWorkloads, jsonSchema, ownerReference)
|
|
if err != nil {
|
|
return cmName, err
|
|
}
|
|
|
|
// Create a configmap to store parameter for each definitionRevision
|
|
defRev := new(v1beta1.DefinitionRevision)
|
|
if err = k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: revName}, defRev); err != nil {
|
|
return "", err
|
|
}
|
|
ownerReference = []metav1.OwnerReference{{
|
|
APIVersion: defRev.APIVersion,
|
|
Kind: defRev.Kind,
|
|
Name: defRev.Name,
|
|
UID: defRev.GetUID(),
|
|
Controller: ptr.To(true),
|
|
BlockOwnerDeletion: ptr.To(true),
|
|
}}
|
|
_, err = def.CreateOrUpdateConfigMap(ctx, k8sClient, namespace, revName, typeTraitDefinition, defRev.Spec.TraitDefinition.Labels, defRev.Spec.TraitDefinition.Spec.AppliesToWorkloads, jsonSchema, ownerReference)
|
|
if err != nil {
|
|
return cmName, err
|
|
}
|
|
return cmName, nil
|
|
}
|
|
|
|
// CapabilityStepDefinition is the Capability struct for WorkflowStepDefinition
|
|
type CapabilityStepDefinition struct {
|
|
Name string `json:"name"`
|
|
StepDefinition v1beta1.WorkflowStepDefinition `json:"stepDefinition"`
|
|
|
|
CapabilityBaseDefinition
|
|
}
|
|
|
|
// NewCapabilityStepDef will create a CapabilityStepDefinition
|
|
func NewCapabilityStepDef(stepdefinition *v1beta1.WorkflowStepDefinition) CapabilityStepDefinition {
|
|
var def CapabilityStepDefinition
|
|
def.Name = stepdefinition.Name
|
|
def.StepDefinition = *stepdefinition.DeepCopy()
|
|
return def
|
|
}
|
|
|
|
// GetOpenAPISchema gets OpenAPI v3 schema by StepDefinition name
|
|
func (def *CapabilityStepDefinition) GetOpenAPISchema(ctx context.Context, name string) ([]byte, error) {
|
|
capability, err := appfile.ConvertTemplateJSON2Object(name, nil, def.StepDefinition.Spec.Schematic)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to convert WorkflowStepDefinition to Capability Object")
|
|
}
|
|
return getOpenAPISchema(ctx, capability)
|
|
}
|
|
|
|
// StoreOpenAPISchema stores OpenAPI v3 schema from StepDefinition in ConfigMap
|
|
func (def *CapabilityStepDefinition) StoreOpenAPISchema(ctx context.Context, k8sClient client.Client, namespace, name string, revName string) (string, error) {
|
|
var jsonSchema []byte
|
|
var err error
|
|
|
|
jsonSchema, err = def.GetOpenAPISchema(ctx, name)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate OpenAPI v3 JSON schema for capability %s: %w", def.Name, err)
|
|
}
|
|
|
|
stepDefinition := def.StepDefinition
|
|
ownerReference := []metav1.OwnerReference{{
|
|
APIVersion: stepDefinition.APIVersion,
|
|
Kind: stepDefinition.Kind,
|
|
Name: stepDefinition.Name,
|
|
UID: stepDefinition.GetUID(),
|
|
Controller: ptr.To(true),
|
|
BlockOwnerDeletion: ptr.To(true),
|
|
}}
|
|
cmName, err := def.CreateOrUpdateConfigMap(ctx, k8sClient, namespace, stepDefinition.Name, typeWorkflowStepDefinition, stepDefinition.Labels, nil, jsonSchema, ownerReference)
|
|
if err != nil {
|
|
return cmName, err
|
|
}
|
|
|
|
// Create a configmap to store parameter for each definitionRevision
|
|
defRev := new(v1beta1.DefinitionRevision)
|
|
if err = k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: revName}, defRev); err != nil {
|
|
return "", err
|
|
}
|
|
ownerReference = []metav1.OwnerReference{{
|
|
APIVersion: defRev.APIVersion,
|
|
Kind: defRev.Kind,
|
|
Name: defRev.Name,
|
|
UID: defRev.GetUID(),
|
|
Controller: ptr.To(true),
|
|
BlockOwnerDeletion: ptr.To(true),
|
|
}}
|
|
_, err = def.CreateOrUpdateConfigMap(ctx, k8sClient, namespace, revName, typeWorkflowStepDefinition, defRev.Spec.WorkflowStepDefinition.Labels, nil, jsonSchema, ownerReference)
|
|
if err != nil {
|
|
return cmName, err
|
|
}
|
|
return cmName, nil
|
|
}
|
|
|
|
// CapabilityPolicyDefinition is the Capability struct for PolicyDefinition
|
|
type CapabilityPolicyDefinition struct {
|
|
Name string `json:"name"`
|
|
PolicyDefinition v1beta1.PolicyDefinition `json:"policyDefinition"`
|
|
|
|
CapabilityBaseDefinition
|
|
}
|
|
|
|
// NewCapabilityPolicyDef will create a CapabilityPolicyDefinition
|
|
func NewCapabilityPolicyDef(policydefinition *v1beta1.PolicyDefinition) CapabilityPolicyDefinition {
|
|
var def CapabilityPolicyDefinition
|
|
def.Name = policydefinition.Name
|
|
def.PolicyDefinition = *policydefinition.DeepCopy()
|
|
return def
|
|
}
|
|
|
|
// GetOpenAPISchema gets OpenAPI v3 schema by StepDefinition name
|
|
func (def *CapabilityPolicyDefinition) GetOpenAPISchema(ctx context.Context, name string) ([]byte, error) {
|
|
capability, err := appfile.ConvertTemplateJSON2Object(name, nil, def.PolicyDefinition.Spec.Schematic)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to convert WorkflowStepDefinition to Capability Object")
|
|
}
|
|
return getOpenAPISchema(ctx, capability)
|
|
}
|
|
|
|
// StoreOpenAPISchema stores OpenAPI v3 schema from StepDefinition in ConfigMap
|
|
func (def *CapabilityPolicyDefinition) StoreOpenAPISchema(ctx context.Context, k8sClient client.Client, namespace, name, revName string) (string, error) {
|
|
var jsonSchema []byte
|
|
var err error
|
|
|
|
jsonSchema, err = def.GetOpenAPISchema(ctx, name)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate OpenAPI v3 JSON schema for capability %s: %w", def.Name, err)
|
|
}
|
|
|
|
policyDefinition := def.PolicyDefinition
|
|
ownerReference := []metav1.OwnerReference{{
|
|
APIVersion: policyDefinition.APIVersion,
|
|
Kind: policyDefinition.Kind,
|
|
Name: policyDefinition.Name,
|
|
UID: policyDefinition.GetUID(),
|
|
Controller: ptr.To(true),
|
|
BlockOwnerDeletion: ptr.To(true),
|
|
}}
|
|
cmName, err := def.CreateOrUpdateConfigMap(ctx, k8sClient, namespace, policyDefinition.Name, typePolicyStepDefinition, policyDefinition.Labels, nil, jsonSchema, ownerReference)
|
|
if err != nil {
|
|
return cmName, err
|
|
}
|
|
|
|
// Create a configmap to store parameter for each definitionRevision
|
|
defRev := new(v1beta1.DefinitionRevision)
|
|
if err = k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: revName}, defRev); err != nil {
|
|
return "", err
|
|
}
|
|
ownerReference = []metav1.OwnerReference{{
|
|
APIVersion: defRev.APIVersion,
|
|
Kind: defRev.Kind,
|
|
Name: defRev.Name,
|
|
UID: defRev.GetUID(),
|
|
Controller: ptr.To(true),
|
|
BlockOwnerDeletion: ptr.To(true),
|
|
}}
|
|
_, err = def.CreateOrUpdateConfigMap(ctx, k8sClient, namespace, revName, typePolicyStepDefinition, defRev.Spec.PolicyDefinition.Labels, nil, jsonSchema, ownerReference)
|
|
if err != nil {
|
|
return cmName, err
|
|
}
|
|
return cmName, nil
|
|
}
|
|
|
|
// CapabilityBaseDefinition is the base struct for CapabilityWorkloadDefinition and CapabilityTraitDefinition
|
|
type CapabilityBaseDefinition struct {
|
|
}
|
|
|
|
// CreateOrUpdateConfigMap creates ConfigMap to store OpenAPI v3 schema or or updates data in ConfigMap
|
|
func (def *CapabilityBaseDefinition) CreateOrUpdateConfigMap(ctx context.Context, k8sClient client.Client, namespace,
|
|
definitionName, definitionType string, labels map[string]string, appliedWorkloads []string, jsonSchema []byte, ownerReferences []metav1.OwnerReference) (string, error) {
|
|
cmName := fmt.Sprintf("%s-%s%s", definitionType, types.CapabilityConfigMapNamePrefix, definitionName)
|
|
var cm v1.ConfigMap
|
|
var data = map[string]string{
|
|
types.OpenapiV3JSONSchema: string(jsonSchema),
|
|
}
|
|
if labels == nil {
|
|
labels = make(map[string]string)
|
|
}
|
|
labels[types.LabelDefinition] = "schema"
|
|
labels[types.LabelDefinitionName] = definitionName
|
|
annotations := make(map[string]string)
|
|
if appliedWorkloads != nil {
|
|
annotations[types.AnnoDefinitionAppliedWorkloads] = strings.Join(appliedWorkloads, ",")
|
|
}
|
|
|
|
// No need to check the existence of namespace, if it doesn't exist, API server will return the error message
|
|
// before it's to be reconciled by ComponentDefinition/TraitDefinition controller.
|
|
err := k8sClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: cmName}, &cm)
|
|
if err != nil && apierrors.IsNotFound(err) {
|
|
cm = v1.ConfigMap{
|
|
TypeMeta: metav1.TypeMeta{
|
|
APIVersion: "v1",
|
|
Kind: "ConfigMap",
|
|
},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: cmName,
|
|
Namespace: namespace,
|
|
OwnerReferences: ownerReferences,
|
|
Labels: labels,
|
|
Annotations: annotations,
|
|
},
|
|
Data: data,
|
|
}
|
|
err = k8sClient.Create(ctx, &cm)
|
|
if err != nil {
|
|
return cmName, fmt.Errorf(util.ErrUpdateCapabilityInConfigMap, definitionName, err)
|
|
}
|
|
klog.InfoS("Successfully stored Capability Schema in ConfigMap", "configMap", klog.KRef(namespace, cmName))
|
|
return cmName, nil
|
|
}
|
|
|
|
cm.Data = data
|
|
cm.Labels = labels
|
|
cm.Annotations = annotations
|
|
if err = k8sClient.Update(ctx, &cm); err != nil {
|
|
return cmName, fmt.Errorf(util.ErrUpdateCapabilityInConfigMap, definitionName, err)
|
|
}
|
|
klog.InfoS("Successfully update Capability Schema in ConfigMap", "configMap", klog.KRef(namespace, cmName))
|
|
return cmName, nil
|
|
}
|
|
|
|
// getOpenAPISchema is the main function for GetDefinition API
|
|
func getOpenAPISchema(ctx context.Context, capability types.Capability) ([]byte, error) {
|
|
s, err := schema.ParsePropertiesToSchema(ctx, capability.CueTemplate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
klog.Infof("parsed %d properties by %s/%s", len(s.Properties), capability.Type, capability.Name)
|
|
parameter, err := s.MarshalJSON()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parameter, nil
|
|
}
|