github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/caas/kubernetes/provider/specs/builder.go (about)

     1  // Copyright 2020 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package specs
     5  
     6  import (
     7  	"bytes"
     8  	"context"
     9  	"fmt"
    10  	"io"
    11  	"sync"
    12  
    13  	"github.com/juju/errors"
    14  	"github.com/kr/pretty"
    15  	k8serrors "k8s.io/apimachinery/pkg/api/errors"
    16  	"k8s.io/apimachinery/pkg/api/meta"
    17  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    18  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    19  	"k8s.io/apimachinery/pkg/runtime"
    20  	apischema "k8s.io/apimachinery/pkg/runtime/schema"
    21  	"k8s.io/apimachinery/pkg/types"
    22  	"k8s.io/apimachinery/pkg/util/yaml"
    23  	"k8s.io/client-go/discovery"
    24  	"k8s.io/client-go/discovery/cached/memory"
    25  	"k8s.io/client-go/kubernetes/scheme"
    26  	"k8s.io/client-go/rest"
    27  	"k8s.io/client-go/restmapper"
    28  
    29  	"github.com/juju/juju/caas"
    30  	k8sannotations "github.com/juju/juju/core/annotations"
    31  )
    32  
    33  var (
    34  	codec            = unstructured.UnstructuredJSONScheme
    35  	metadataAccessor = meta.NewAccessor()
    36  )
    37  
    38  func processRawData(data []byte, defaults *apischema.GroupVersionKind, into runtime.Object) (obj runtime.Object, gvk *apischema.GroupVersionKind, err error) {
    39  	obj, gvk, err = codec.Decode(data, defaults, into)
    40  	if err != nil {
    41  		return obj, gvk, errors.Trace(err)
    42  	}
    43  
    44  	if _, ok := obj.(runtime.Unstructured); !ok {
    45  		return obj, gvk, errors.Trace(nil)
    46  	}
    47  
    48  	return obj, gvk, nil
    49  }
    50  
    51  type resourceInfo struct {
    52  	name            string
    53  	namespace       string
    54  	resourceVersion string
    55  	content         *runtime.RawExtension
    56  
    57  	mapping *meta.RESTMapping
    58  	client  rest.Interface
    59  }
    60  
    61  //go:generate go run go.uber.org/mock/mockgen -package mocks -destination mocks/meta_mock.go k8s.io/apimachinery/pkg/api/meta RESTMapper
    62  func getRestMapper(c rest.Interface) meta.RESTMapper {
    63  	discoveryClient := discovery.NewDiscoveryClient(c)
    64  	mapper := restmapper.NewDeferredDiscoveryRESTMapper(
    65  		memory.NewMemCacheClient(discoveryClient),
    66  	)
    67  	return restmapper.NewShortcutExpander(mapper, discoveryClient, func(warning string) {
    68  		logger.Warningf(warning)
    69  	})
    70  }
    71  
    72  func (ri *resourceInfo) withNamespace(namespace string) *resourceInfo {
    73  	if ri.namespace != "" && ri.namespace != namespace {
    74  		logger.Debugf("namespace is force set from %q to %q", ri.namespace, namespace)
    75  	}
    76  	ri.namespace = namespace
    77  	_ = metadataAccessor.SetNamespace(ri.content.Object, ri.namespace)
    78  	return ri
    79  }
    80  
    81  func (ri *resourceInfo) ensureLabels(labels map[string]string) error {
    82  	providedLabels, err := metadataAccessor.Labels(ri.content.Object)
    83  	if err != nil {
    84  		return errors.Trace(err)
    85  	}
    86  	if len(providedLabels) == 0 {
    87  		providedLabels = make(map[string]string)
    88  	}
    89  	for k, v := range labels {
    90  		providedLabels[k] = v
    91  	}
    92  	return metadataAccessor.SetLabels(ri.content.Object, providedLabels)
    93  }
    94  
    95  func (ri *resourceInfo) ensureAnnotations(annoations k8sannotations.Annotation) error {
    96  	providedAnnoations, err := metadataAccessor.Annotations(ri.content.Object)
    97  	if err != nil {
    98  		return errors.Trace(err)
    99  	}
   100  	return metadataAccessor.SetAnnotations(
   101  		ri.content.Object, k8sannotations.New(providedAnnoations).Merge(annoations).ToMap(),
   102  	)
   103  }
   104  
   105  func getWorkloadResourceType(t caas.DeploymentType) string {
   106  	switch t {
   107  	case caas.DeploymentDaemon:
   108  		return "daemonsets"
   109  	case caas.DeploymentStateless:
   110  		return "deployments"
   111  	case caas.DeploymentStateful:
   112  		return "statefulsets"
   113  	default:
   114  		return "deployments"
   115  	}
   116  }
   117  
   118  type deployer struct {
   119  	deploymentName       string
   120  	namespace            string
   121  	spec                 string
   122  	workloadResourceType string
   123  	cfg                  *rest.Config
   124  	labelGetter          func(isNamespaced bool) map[string]string
   125  	annotations          k8sannotations.Annotation
   126  	newRestClient        NewK8sRestClientFunc
   127  
   128  	resources []resourceInfo
   129  
   130  	restMapperGetter func(c rest.Interface) meta.RESTMapper
   131  }
   132  
   133  // DeployerInterface defines method to deploy a raw k8s spec.
   134  type DeployerInterface interface {
   135  	Deploy(context.Context, string, bool) error
   136  }
   137  
   138  // NewK8sRestClientFunc defines a function which returns a k8s rest client based on the supplied config.
   139  type NewK8sRestClientFunc func(c *rest.Config) (rest.Interface, error)
   140  
   141  // New constructs deployer interface.
   142  func New(
   143  	deploymentName string,
   144  	namespace string,
   145  	deploymentParams caas.DeploymentParams,
   146  	cfg *rest.Config,
   147  	labelGetter func(isNamespaced bool) map[string]string,
   148  	annotations k8sannotations.Annotation,
   149  	newRestClient NewK8sRestClientFunc,
   150  ) DeployerInterface {
   151  	// TODO(caas): disable scale or parse the unstructuredJSON further to set workload resource replicas.
   152  	return newDeployer(
   153  		deploymentName, namespace,
   154  		deploymentParams, cfg, labelGetter, annotations,
   155  		newRestClient, getRestMapper,
   156  	)
   157  }
   158  
   159  func newDeployer(
   160  	deploymentName string,
   161  	namespace string,
   162  	deploymentParams caas.DeploymentParams,
   163  	cfg *rest.Config,
   164  	labelGetter func(isNamespaced bool) map[string]string,
   165  	annotations k8sannotations.Annotation,
   166  	newRestClient NewK8sRestClientFunc,
   167  	restMapperGetter func(c rest.Interface) meta.RESTMapper,
   168  ) DeployerInterface {
   169  	return &deployer{
   170  		deploymentName:       deploymentName,
   171  		namespace:            namespace,
   172  		workloadResourceType: getWorkloadResourceType(deploymentParams.DeploymentType),
   173  		cfg:                  cfg,
   174  		labelGetter:          labelGetter,
   175  		annotations:          annotations,
   176  		newRestClient:        newRestClient,
   177  		restMapperGetter:     restMapperGetter,
   178  	}
   179  }
   180  
   181  func (d *deployer) validate() error {
   182  	if len(d.namespace) == 0 {
   183  		return errors.NotValidf("namespace is required")
   184  	}
   185  	if len(d.workloadResourceType) == 0 {
   186  		return errors.NotValidf("workloadResourceType is required")
   187  	}
   188  	if d.cfg == nil {
   189  		return errors.NotValidf("empty k8s config")
   190  	}
   191  	if d.labelGetter == nil {
   192  		return errors.NotValidf("labelGetter is required")
   193  	}
   194  	if d.newRestClient == nil {
   195  		return errors.NotValidf("newRestClient is required")
   196  	}
   197  
   198  	if err := d.load(); err != nil {
   199  		return errors.Trace(err)
   200  	}
   201  	if err := d.validateWorkload(); err != nil {
   202  		return errors.Trace(err)
   203  	}
   204  	// TODO(caas): check if service resource type matches the raw service spec.
   205  	// TODO(caas): get the API scheme and do validation further.
   206  	return nil
   207  }
   208  
   209  // Deploy deploys raw k8s spec to the cluster.
   210  func (d *deployer) Deploy(ctx context.Context, spec string, force bool) error {
   211  	d.spec = spec
   212  
   213  	if err := d.validate(); err != nil {
   214  		return errors.Trace(err)
   215  	}
   216  	var wg sync.WaitGroup
   217  	wg.Add(len(d.resources))
   218  
   219  	errChan := make(chan error)
   220  	done := make(chan struct{})
   221  	go func() {
   222  		wg.Wait()
   223  		close(done)
   224  	}()
   225  
   226  	for _, r := range d.resources {
   227  		info := r
   228  		go func() { _ = d.apply(ctx, &wg, info, force, errChan) }()
   229  	}
   230  
   231  	for {
   232  		select {
   233  		case err := <-errChan:
   234  			if err != nil {
   235  				return errors.Trace(err)
   236  			}
   237  		case <-done:
   238  			return nil
   239  		}
   240  	}
   241  }
   242  
   243  func (d *deployer) validateWorkload() error {
   244  	for _, resource := range d.resources {
   245  		if resource.mapping.Resource.Resource == d.workloadResourceType {
   246  			return nil
   247  		}
   248  	}
   249  	return errors.NotValidf("empty %q resource definition", d.workloadResourceType)
   250  }
   251  
   252  func setConfigDefaults(config *rest.Config) {
   253  	if config.ContentConfig.NegotiatedSerializer == nil {
   254  		config.ContentConfig.NegotiatedSerializer = scheme.Codecs.WithoutConversion()
   255  	}
   256  	if len(config.UserAgent) == 0 {
   257  		config.UserAgent = rest.DefaultKubernetesUserAgent()
   258  	}
   259  }
   260  
   261  func (d *deployer) clientWithGroupVersion(gv apischema.GroupVersion) (rest.Interface, error) {
   262  	cfg := rest.CopyConfig(d.cfg)
   263  	setConfigDefaults(cfg)
   264  
   265  	cfg.APIPath = "/apis"
   266  	if len(gv.Group) == 0 {
   267  		cfg.APIPath = "/api"
   268  	}
   269  	cfg.GroupVersion = &gv
   270  
   271  	logger.Debugf("constructing rest client for resource %s for %q", pretty.Sprint(cfg.GroupVersion), d.deploymentName)
   272  	return d.newRestClient(cfg)
   273  }
   274  
   275  // load parses the raw k8s spec into a slice of resource info.
   276  func (d *deployer) load() (err error) {
   277  	defer func() {
   278  		logger.Debugf("processing %d resources for %q, err -> %#v", len(d.resources), d.deploymentName, err)
   279  	}()
   280  
   281  	d.resources = []resourceInfo{}
   282  
   283  	decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewBufferString(d.spec), len(d.spec))
   284  	for {
   285  		ext := &runtime.RawExtension{}
   286  		if err = decoder.Decode(ext); err != nil {
   287  			if err == io.EOF {
   288  				return nil
   289  			}
   290  			return errors.Trace(err)
   291  		}
   292  		ext.Raw = bytes.TrimSpace(ext.Raw)
   293  		if len(ext.Raw) == 0 || bytes.Equal(ext.Raw, []byte("null")) {
   294  			continue
   295  		}
   296  
   297  		var gvk *apischema.GroupVersionKind
   298  		ext.Object, gvk, err = processRawData(ext.Raw, nil, nil)
   299  		if err != nil {
   300  			return errors.Trace(err)
   301  		}
   302  
   303  		item := resourceInfo{
   304  			content: ext,
   305  		}
   306  
   307  		item.name, err = metadataAccessor.Name(item.content.Object)
   308  		if err != nil {
   309  			return errors.Trace(err)
   310  		}
   311  
   312  		item.namespace, err = metadataAccessor.Namespace(item.content.Object)
   313  		if err != nil {
   314  			return errors.Trace(err)
   315  		}
   316  
   317  		item.resourceVersion, err = metadataAccessor.ResourceVersion(item.content.Object)
   318  		if err != nil {
   319  			return errors.Trace(err)
   320  		}
   321  
   322  		if item.client, err = d.clientWithGroupVersion(gvk.GroupVersion()); err != nil {
   323  			return errors.Trace(err)
   324  		}
   325  		item.mapping, err = d.restMapperGetter(item.client).RESTMapping(gvk.GroupKind(), gvk.Version)
   326  		logger.Tracef("gvk.GroupKind() %s, gvk.Version %q, item.mapping %s", pretty.Sprint(gvk.GroupKind()), gvk.Version, pretty.Sprint(item.mapping))
   327  		if err != nil {
   328  			return errors.Trace(err)
   329  		}
   330  
   331  		d.resources = append(d.resources, item)
   332  	}
   333  }
   334  
   335  // apply deploys the resource info to the k8s cluster.
   336  func (d deployer) apply(ctx context.Context, wg *sync.WaitGroup, info resourceInfo, force bool, errChan chan<- error) (err error) {
   337  	defer wg.Done()
   338  
   339  	defer func() {
   340  		if err != nil {
   341  			select {
   342  			case errChan <- err:
   343  			default:
   344  			}
   345  		}
   346  	}()
   347  
   348  	isNameSpaced := info.mapping.Scope.Name() == meta.RESTScopeNameNamespace
   349  
   350  	// Ensures namespace is set.
   351  	_ = info.withNamespace(d.namespace)
   352  	// Ensure Juju labels are set.
   353  	if err = info.ensureLabels(d.labelGetter(isNameSpaced)); err != nil {
   354  		return errors.Trace(err)
   355  	}
   356  	// Ensure annotations are set.
   357  	if err = info.ensureAnnotations(d.annotations); err != nil {
   358  		return errors.Trace(err)
   359  	}
   360  
   361  	var data []byte
   362  	data, err = runtime.Encode(codec, info.content.Object)
   363  	if err != nil {
   364  		return errors.Trace(err)
   365  	}
   366  	options := &metav1.PatchOptions{
   367  		Force:        &force,
   368  		FieldManager: "juju",
   369  	}
   370  
   371  	doRequest := func(r *rest.Request) error {
   372  		err := r.NamespaceIfScoped(info.namespace, isNameSpaced).
   373  			Resource(info.mapping.Resource.Resource).
   374  			Name(info.name).
   375  			VersionedParams(options, metav1.ParameterCodec).
   376  			Body(data).
   377  			Do(ctx).
   378  			Error()
   379  		errMsg := fmt.Sprintf("resource %s/%s in namespace %q", info.mapping.GroupVersionKind.Kind, info.name, d.namespace)
   380  		if k8serrors.IsNotFound(err) {
   381  			return errors.NotFoundf(errMsg)
   382  		}
   383  		if k8serrors.IsAlreadyExists(err) {
   384  			return errors.AlreadyExistsf(errMsg)
   385  		}
   386  		return errors.Trace(err)
   387  	}
   388  
   389  	err = doRequest(info.client.Patch(types.ApplyPatchType))
   390  	if errors.IsNotFound(err) {
   391  		err = doRequest(info.client.Post())
   392  	}
   393  	return errors.Trace(err)
   394  }