gke-deploy/core/resource/resource.go (579 lines of code) (raw):
// Package resource contains logic related to Kubernetes resource objects.
package resource
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"text/tabwriter"
"github.com/google/go-containerregistry/pkg/name"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer/json"
"k8s.io/apimachinery/pkg/util/yaml"
"github.com/GoogleCloudPlatform/cloud-builders/gke-deploy/core/image"
"github.com/GoogleCloudPlatform/cloud-builders/gke-deploy/services"
)
// AggregatedFilename is the filename for the file created by SaveAsConfigs.
const AggregatedFilename = "aggregated-resources.yaml"
type resourceDecoder struct{}
func (resourceDecoder) Decode(data []byte, defaults *schema.GroupVersionKind, into runtime.Object) (runtime.Object, *schema.GroupVersionKind, error) {
json, err := yaml.ToJSON(data)
if err != nil {
return nil, nil, err
}
return unstructured.UnstructuredJSONScheme.Decode(json, defaults, into)
}
var (
decoder = resourceDecoder{}
encoder = json.NewSerializerWithOptions(json.DefaultMetaFactory, nil, nil, json.SerializerOptions{Yaml: true})
)
// Objects maps resource file base names to corresponding resource objects (mutable).
type Objects []*Object
// Object extends unstructured.Unstructured, which implements runtime.Object
type Object struct {
*unstructured.Unstructured
}
// EncodeToYAMLString encodes an object from *Object to a string.
func EncodeToYAMLString(obj *Object) (string, error) {
out, err := runtime.Encode(encoder, obj)
if err != nil {
return "", fmt.Errorf("failed to encode resource: %v", err)
}
return string(out), nil
}
// DecodeFromYAML decodes an object from a YAML string as bytes.
func DecodeFromYAML(ctx context.Context, yaml []byte) (*Object, error) {
obj, err := runtime.Decode(decoder, yaml)
if err != nil {
return nil, fmt.Errorf("failed to decode yaml into object: %s", err)
}
objUn, ok := obj.(*unstructured.Unstructured)
if !ok {
return nil, fmt.Errorf("failed to convert object to Unstructured")
}
return &Object{
objUn,
}, nil
}
// ParseConfigs parses resource objects from a file or directory of files into a map that maps
// unique file base names to the parsed objects.
func ParseConfigs(ctx context.Context, configs string, oss services.OSService, recursive bool) (Objects, error) {
objs := Objects{}
if configs == "-" {
if recursive {
return nil, fmt.Errorf("cannot recur with stdin")
}
objs, err := parseResourcesFromFile(ctx, configs, objs, oss)
if err != nil {
return nil, fmt.Errorf("failed to parse config from stdin: %v", err)
}
return objs, nil
}
fi, err := oss.Stat(ctx, configs)
if err != nil {
return nil, fmt.Errorf("failed to get file info for %q: %v", configs, err)
}
if !fi.IsDir() && recursive {
return nil, fmt.Errorf("cannot recur through a file")
}
hasResources := false
// Since walk is recursive, we need to declare it before creating the function that refers to it.
var walk func(path string, fi os.FileInfo, baseDir bool) error
walk = func(path string, fi os.FileInfo, baseDir bool) error {
if fi.IsDir() {
if !baseDir && !recursive {
return nil
}
subfiles, err := oss.ReadDir(ctx, path)
if err != nil {
return fmt.Errorf("failed to list files in directory %q: %v", path, err)
}
for _, subfile := range subfiles {
subpath := filepath.Join(path, subfile.Name())
if err = walk(subpath, subfile, false); err != nil {
return err
}
}
} else {
if hasYamlOrYmlSuffix(path) {
hasResources = true
objs, err = parseResourcesFromFile(ctx, path, objs, oss)
if err != nil {
return fmt.Errorf("failed to parse config %q: %v", path, err)
}
}
}
return nil
}
if err = walk(configs, fi, true); err != nil {
return nil, err
}
if !hasResources {
if fi.IsDir() {
return nil, fmt.Errorf("directory %q has no \".yaml\" or \".yml\" files to parse", configs)
}
return nil, fmt.Errorf("file %q does not end in \".yaml\" or \".yml\"", configs)
}
return objs, nil
}
// SaveAsConfigs saves resource objects as config files to a target output directory.
// If any lines in a resource object's string representation contain a key in
// lineComments, the corresponding value will be added as a comment at the end of
// the line. The string being returned is the path of the saved file.
func SaveAsConfigs(ctx context.Context, objs Objects, outputDir string, lineComments map[string]string, oss services.OSService) (string, error) {
fi, err := oss.Stat(ctx, outputDir)
if err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("failed to get file info for output directory %q: %v", outputDir, err)
}
if err == nil && !fi.IsDir() {
return "", fmt.Errorf("output directory %q exists as a file", outputDir)
}
if err == nil && fi.IsDir() {
files, err := oss.ReadDir(ctx, outputDir)
if err != nil {
return "", fmt.Errorf("failed to list files in output directory %q: %v", outputDir, err)
}
if len(files) != 0 {
return "", fmt.Errorf("output directory %q exists and is not empty", outputDir)
}
}
if err := oss.MkdirAll(ctx, outputDir, os.ModePerm); err != nil {
return "", fmt.Errorf("failed to create output directory %q: %v", outputDir, err)
}
filename := filepath.Join(outputDir, AggregatedFilename)
resources := make([]string, 0, len(objs))
for _, obj := range objs {
out, err := runtime.Encode(encoder, obj)
if err != nil {
return "", fmt.Errorf("failed to encode resource: %v", err)
}
outWithComments, err := addCommentsToLines(string(out), lineComments)
if err != nil {
return "", fmt.Errorf("failed to add comment to object file: %v", err)
}
resources = append(resources, outWithComments)
}
contents := strings.Join(resources, "\n\n---\n\n")
if err := oss.WriteFile(ctx, filename, []byte(contents), 0644); err != nil {
return "", fmt.Errorf("failed to write file %q: %v", filename, err)
}
return filename, nil
}
// addCommentsToLines iterates through the lines of a string ('-n'-delimited)
// and if any lines contain a key in lineComments, the corresponding value will
// be added as a comment at the end of the line. This function returns the
// modified string.
func addCommentsToLines(s string, lineComments map[string]string) (string, error) {
lines := strings.Split(s, "\n")
lineIdx := 0
for _, line := range lines {
for stringToContain, comment := range lineComments {
if strings.Contains(stringToContain, "\n") {
return "", fmt.Errorf("line cannot contain a newline character")
}
if strings.Contains(comment, "\n") {
return "", fmt.Errorf("comment cannot contain a newline character")
}
if strings.Contains(line, stringToContain) {
lines[lineIdx] = fmt.Sprintf("%s # %s", line, comment)
}
}
lineIdx++
}
return strings.Join(lines, "\n"), nil
}
// UpdateMatchingContainerImage updates all objects that have container images matching the provided image
// name with the provided replacement string.
func UpdateMatchingContainerImage(ctx context.Context, objs Objects, imageName, replace string) error {
matched := false
for _, obj := range objs {
var nestedFields []string
switch kind := ObjectKind(obj); kind {
case "CronJob":
nestedFields = []string{"spec", "jobTemplate", "spec", "template", "spec", "containers"}
case "Pod":
nestedFields = []string{"spec", "containers"}
case "DaemonSet", "Deployment", "Job", "ReplicaSet", "ReplicationController", "StatefulSet":
nestedFields = []string{"spec", "template", "spec", "containers"}
default:
continue
}
cons, ok, err := unstructured.NestedFieldNoCopy(obj.Object, nestedFields...)
if err != nil {
return fmt.Errorf("failed to get nested containers field: %v", err)
}
if !ok {
continue
}
consList, ok := cons.([]interface{})
if !ok {
return fmt.Errorf("failed to convert containers to list")
}
for _, con := range consList {
conMap, ok := con.(map[string]interface{})
if !ok {
return fmt.Errorf("failed to convert container to map")
}
im, ok, err := unstructured.NestedString(conMap, "image")
if err != nil {
return fmt.Errorf("failed to get image field: %v", err)
}
if !ok {
continue
}
ref, err := name.ParseReference(im)
if err != nil {
return fmt.Errorf("failed to parse reference from image %q: %v", im, err)
}
if image.Name(ref) == imageName {
fmt.Printf("Updating container of resource: %v\n", obj)
if err := unstructured.SetNestedField(conMap, replace, "image"); err != nil {
return fmt.Errorf("failed to set image field: %v", err)
}
matched = true
}
}
}
if !matched {
fmt.Fprintf(os.Stderr, "\nWARNING: Did not find any resources with a container that has image name %q\n\n", imageName)
}
return nil
}
// UpdateNamespace updates all objects to change its namespace to the provided namespace. Objects
// that do not have a namespace field will also be updated to have a namespace field.
func UpdateNamespace(ctx context.Context, objs Objects, replace string) error {
for _, obj := range objs {
if err := setObjectNamespace(obj, replace); err != nil {
return fmt.Errorf("failed to set namespace field: %v", err)
}
}
return nil
}
// AddNamespaceIfMissing updates all objects to add a namespace only if the object does
// not have one already.
func AddNamespaceIfMissing(objs Objects, namespace string) error {
for _, obj := range objs {
ns, err := ObjectNamespace(obj)
if err != nil {
return fmt.Errorf("failed to get namespace field: %v", err)
}
if ns != "" {
continue
}
if err := setObjectNamespace(obj, namespace); err != nil {
return fmt.Errorf("failed to set namespace field: %v", err)
}
}
return nil
}
// HasObject returns true if there exists an object in objs that matches the provided kind and
// name.
func HasObject(ctx context.Context, objs Objects, kind, name string) (bool, error) {
for _, obj := range objs {
objKind := ObjectKind(obj)
objName, err := ObjectName(obj)
if err != nil {
return false, fmt.Errorf("failed to get resource name: %v", err)
}
if objKind == kind && objName == name {
return true, nil
}
}
return false, nil
}
// CreateDeploymentObject creates a Deployment object with the given name and image.
// The created Deployment will have 3 replicas.
func CreateDeploymentObject(ctx context.Context, name string, selectorValue, image string) (*Object, error) {
obj, err := DecodeFromYAML(ctx, []byte(fmt.Sprintf(deploymentTemplate, name, "app", selectorValue, "app", selectorValue, name, image)))
if err != nil {
return nil, fmt.Errorf("failed to decode Deployment object from template: %v", err)
}
return obj, nil
}
// CreateHorizontalPodAutoscalerObject creates a Namespace object with the given name.
// The created HorizontalPodAutoscaler will have minReplicas set to 1, maxReplicas set to 5, and a
// cpu targetAverageUtilization of 80.
func CreateHorizontalPodAutoscalerObject(ctx context.Context, name, deploymentName string) (*Object, error) {
obj, err := DecodeFromYAML(ctx, []byte(fmt.Sprintf(horizontalPodAutoscalerTemplate, name, deploymentName)))
if err != nil {
return nil, fmt.Errorf("failed to decode HorizontalPodAutoscaler object from template: %v", err)
}
return obj, nil
}
// CreateNamespaceObject creates a Namespace object with the given name.
func CreateNamespaceObject(ctx context.Context, name string) (*Object, error) {
if name == "default" {
return nil, fmt.Errorf("namespace name should not be \"default\"")
}
obj, err := DecodeFromYAML(ctx, []byte(fmt.Sprintf(namespaceTemplate, name)))
if err != nil {
return nil, fmt.Errorf("failed to decode Namespace object from template: %v", err)
}
return obj, nil
}
// CreateServiceObject creates a Service object with the given name with service type LoadBalancer.
func CreateServiceObject(ctx context.Context, name, selectorKey, selectorValue string, port int) (*Object, error) {
obj, err := DecodeFromYAML(ctx, []byte(fmt.Sprintf(serviceTemplate, name, selectorKey, selectorValue, port, port)))
if err != nil {
return nil, fmt.Errorf("failed to decode Service object from template")
}
return obj, nil
}
// DeploySummary returns a string representation of a summary of a list of objects' deploy statuses.
func DeploySummary(ctx context.Context, objs Objects) (string, error) {
// Sort values
var sorted []*Object
for _, obj := range objs {
sorted = append(sorted, obj)
}
sorted = sortObjectsByKindAndName(sorted)
// Create table
padding := 4
buf := new(bytes.Buffer)
w := tabwriter.NewWriter(buf, 0, 0, padding, ' ', 0)
if _, err := fmt.Fprintln(w, "NAMESPACE\tKIND\tNAME\tREADY\t"); err != nil {
return "", fmt.Errorf("failed to write to writer: %v", err)
}
for _, obj := range sorted {
kind := ObjectKind(obj)
name, err := ObjectName(obj)
if err != nil {
return "", fmt.Errorf("failed to get resource name: %v", err)
}
namespace, err := ObjectNamespace(obj)
if err != nil {
return "", fmt.Errorf("failed to get namespace of object: %v", err)
}
if namespace == "" {
namespace = "default"
}
extraInfo, err := deploySummaryExtraInfo(obj)
if err != nil {
return "", fmt.Errorf("failed to get resource summary extra info: %v", err)
}
var ready string
ok, err := IsReady(ctx, obj)
if err != nil {
ready = "Unknown"
} else if ok {
ready = "Yes"
} else {
ready = "No"
}
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", namespace, kind, name, ready, extraInfo); err != nil {
return "", fmt.Errorf("failed to write to writer: %v", err)
}
}
if err := w.Flush(); err != nil {
return "", fmt.Errorf("failed to flush writer: %v", err)
}
return buf.String(), nil
}
// sortObjectsByKindAndName sorts a list of objects by kind, then name, alphabetically.
func sortObjectsByKindAndName(objs []*Object) []*Object {
sort.SliceStable(objs, func(i, j int) bool {
a := objs[i]
b := objs[j]
aKind := ObjectKind(a)
bKind := ObjectKind(b)
aName, err := ObjectName(a)
if err != nil {
return false // Move a to end of slice
}
bName, err := ObjectName(b)
if err != nil {
return true // Move b to end of slice
}
if aKind == bKind {
return aName < bName
}
return aKind < bKind
})
return objs
}
func deploySummaryExtraInfo(obj *Object) (string, error) {
var extraInfo string
kind := ObjectKind(obj)
switch kind {
case "Service":
serviceType, ok, err := unstructured.NestedString(obj.Object, "spec", "type")
if err != nil {
return "", fmt.Errorf("failed to get spec.type field: %v", err)
}
if !ok || serviceType == "" {
return "", fmt.Errorf("spec.type field is missing or is empty")
}
switch serviceType {
case "LoadBalancer":
return serviceIPs(obj)
case "ExternalName":
return serviceExternalName(obj)
}
default:
}
return extraInfo, nil
}
func serviceIPs(obj *Object) (string, error) {
ports, ok, err := unstructured.NestedSlice(obj.Object, "spec", "ports")
if err != nil {
return "", fmt.Errorf("failed to get spec.ports field: %v", err)
}
if !ok || len(ports) == 0 {
return "", nil
}
portMap := ports[0].(map[string]interface{})
if !ok {
return "", fmt.Errorf("failed to convert port to map")
}
port, ok, err := unstructured.NestedInt64(portMap, "port")
if err != nil {
return "", fmt.Errorf("failed to get port field: %v", err)
}
if !ok {
return "", fmt.Errorf("port field is missing")
}
ingress, ok, err := unstructured.NestedSlice(obj.Object, "status", "loadBalancer", "ingress")
if err != nil {
return "", fmt.Errorf("failed to get status.loadBalancer.ingress field: %v", err)
}
if !ok || len(ingress) == 0 {
return "", nil
}
var ips []string
for _, i := range ingress {
iMap, ok := i.(map[string]interface{})
if !ok {
return "", fmt.Errorf("failed to convert ingress to map")
}
ip, ok, err := unstructured.NestedString(iMap, "ip")
if err != nil {
return "", fmt.Errorf("failed to get ip field: %v", err)
}
if !ok || ip == "" {
return "", fmt.Errorf("ip field is missing or is empty")
}
if port != 80 {
ip = fmt.Sprintf("%s:%d", ip, port)
}
ips = append(ips, fmt.Sprintf("http://%s", ip))
}
return strings.Join(ips, ", "), nil
}
func serviceExternalName(obj *Object) (string, error) {
externalName, ok, err := unstructured.NestedString(obj.Object, "spec", "externalName")
if err != nil {
return "", fmt.Errorf("failed to get spec.externalName field: %v", err)
}
if !ok {
return "", fmt.Errorf("spec.externalName field is missing")
}
return externalName, nil
}
func parseResourcesFromFile(ctx context.Context, filename string, objs Objects, oss services.OSService) (Objects, error) {
readStdin := filename == "-"
var printFilename string
if readStdin {
printFilename = "stdin"
} else {
printFilename = fmt.Sprintf("file %q", filename)
}
in, err := oss.ReadFile(ctx, filename)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %v", printFilename, err)
}
if readStdin {
filename = "k8s.yaml" // Files parsed from stdin will have the prefix "k8s".
}
split := strings.Split(string(in), "\n---")
for i, r := range split {
lines := strings.Split(r, "\n")
onlyCommentsAndWhitespace := true
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
if trimmedLine != "" && !strings.HasPrefix(trimmedLine, "#") {
onlyCommentsAndWhitespace = false
break
}
}
if onlyCommentsAndWhitespace {
continue
}
obj, err := DecodeFromYAML(ctx, []byte(r))
if err != nil {
return nil, fmt.Errorf("failed to decode resource from item %d in %s: %v", i+1, printFilename, err)
}
objs = append(objs, obj)
}
return objs, nil
}
// String returns a string representation of objects.
func (objs Objects) String() string {
return fmt.Sprintf("%v", sortObjectsByKindAndName(objs))
}
// String returns a string representation of an object.
func (obj *Object) String() string {
kind := ObjectKind(obj)
name, err := ObjectName(obj)
if err != nil {
name = "UNKNOWN"
}
return fmt.Sprintf("{kind: %s, name: %s}", kind, name)
}
// AddLabel updates an object to add a label with the key and value provided.
func AddLabel(ctx context.Context, obj *Object, key, value string, override bool) error {
if key == "" || value == "" {
return fmt.Errorf("key and value cannot be empty")
}
if err := addToNestedMap(obj, key, value, override, "metadata", "labels"); err != nil {
return err
}
var nestedFields []string
switch kind := ObjectKind(obj); kind {
case "CronJob":
nestedFields = []string{"spec", "jobTemplate", "spec", "template", "metadata", "labels"}
case "DaemonSet", "Deployment", "Job", "ReplicaSet", "ReplicationController", "StatefulSet":
nestedFields = []string{"spec", "template", "metadata", "labels"}
default:
return nil
}
if err := addToNestedMap(obj, key, value, override, nestedFields...); err != nil {
return err
}
return nil
}
// AddAnnotation updates an object to add an annotation with the key and value
// provided.
func AddAnnotation(obj *Object, key, value string) error {
if key == "" || value == "" {
return fmt.Errorf("key and value cannot be empty")
}
if err := addToNestedMap(obj, key, value, true, "metadata", "annotations"); err != nil {
return err
}
var nestedFields []string
switch kind := ObjectKind(obj); kind {
case "CronJob":
nestedFields = []string{"spec", "jobTemplate", "spec", "template", "metadata", "annotations"}
case "DaemonSet", "Deployment", "Job", "ReplicaSet", "ReplicationController", "StatefulSet":
nestedFields = []string{"spec", "template", "metadata", "annotations"}
default:
return nil
}
if err := addToNestedMap(obj, key, value, true, nestedFields...); err != nil {
return err
}
return nil
}
func addToNestedMap(obj *Object, key, value string, override bool, nestedFields ...string) error {
mapField, ok, err := unstructured.NestedMap(obj.Object, nestedFields...)
if err != nil {
return fmt.Errorf("failed to get map field: %v", err)
}
if !ok {
mapField = make(map[string]interface{})
}
if existing, ok := mapField[key]; ok && !override {
if existing != value {
fmt.Fprintf(os.Stderr, "\nWARNING: Key %q is already set as %q for object %v in %v field. Not overriding.\n", key, existing, obj, strings.Join(nestedFields, "."))
}
} else {
mapField[key] = value
if err := unstructured.SetNestedMap(obj.Object, mapField, nestedFields...); err != nil {
return fmt.Errorf("failed to set map field: %v", err)
}
}
return nil
}
// TODO(joonlim): These should be member functions of Object.
// ObjectKind returns the kind of an object.
func ObjectKind(obj *Object) string {
return obj.GetObjectKind().GroupVersionKind().Kind
}
// ObjectName returns the name of an object.
func ObjectName(obj *Object) (string, error) {
accessor, err := meta.Accessor(obj)
if err != nil {
return "", fmt.Errorf("failed to get metadata accessor from object: %v", err)
}
return accessor.GetName(), nil
}
// ObjectNamespace returns the namespace of an object.
func ObjectNamespace(obj *Object) (string, error) {
accessor, err := meta.Accessor(obj)
if err != nil {
return "", fmt.Errorf("failed to get metadata accessor from object: %v", err)
}
return accessor.GetNamespace(), nil
}
func setObjectNamespace(obj *Object, namespace string) error {
accessor, err := meta.Accessor(obj)
if err != nil {
return fmt.Errorf("failed to get metadata accessor from object: %v", err)
}
accessor.SetNamespace(namespace)
return nil
}
func hasYamlOrYmlSuffix(filename string) bool {
return strings.HasSuffix(filename, ".yaml") || strings.HasSuffix(filename, ".yml")
}