diff --git a/config/crd/bases/getporter.org_agentactions.yaml b/config/crd/bases/getporter.org_agentactions.yaml index 816c83ce..09ad41cb 100644 --- a/config/crd/bases/getporter.org_agentactions.yaml +++ b/config/crd/bases/getporter.org_agentactions.yaml @@ -3,8 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.8.0 - creationTimestamp: null + controller-gen.kubebuilder.io/version: v0.12.0 name: agentactions.getporter.org spec: group: getporter.org @@ -44,6 +43,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic args: description: Args to pass to the Porter Agent job. This should be the porter command that you want to run. @@ -97,6 +97,7 @@ spec: required: - key type: object + x-kubernetes-map-type: atomic fieldRef: description: 'Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['''']`, `metadata.annotations['''']`, @@ -114,6 +115,7 @@ spec: required: - fieldPath type: object + x-kubernetes-map-type: atomic resourceFieldRef: description: 'Selects a resource of the container: only resources limits and requests (limits.cpu, limits.memory, @@ -138,6 +140,7 @@ spec: required: - resource type: object + x-kubernetes-map-type: atomic secretKeyRef: description: Selects a key of a secret in the pod's namespace properties: @@ -156,6 +159,7 @@ spec: required: - key type: object + x-kubernetes-map-type: atomic type: object required: - name @@ -178,6 +182,7 @@ spec: description: Specify whether the ConfigMap must be defined type: boolean type: object + x-kubernetes-map-type: atomic prefix: description: An optional identifier to prepend to each key in the ConfigMap. Must be a C_IDENTIFIER. @@ -193,6 +198,7 @@ spec: description: Specify whether the Secret must be defined type: boolean type: object + x-kubernetes-map-type: atomic type: object type: array files: @@ -370,6 +376,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic user: description: 'user is optional: User is the rados user name, default is admin More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' @@ -401,6 +408,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic volumeID: description: 'volumeID used to identify the volume in cinder. More info: https://examples.k8s.io/mysql-cinder-pd/README.md' @@ -472,6 +480,7 @@ spec: keys must be defined type: boolean type: object + x-kubernetes-map-type: atomic csi: description: csi (Container Storage Interface) represents ephemeral storage that is handled by certain external CSI drivers (Beta @@ -502,6 +511,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic readOnly: description: readOnly specifies a read-only configuration for the volume. Defaults to false (read/write). @@ -555,6 +565,7 @@ spec: required: - fieldPath type: object + x-kubernetes-map-type: atomic mode: description: 'Optional: mode bits used to set permissions on this file, must be an octal value between 0000 @@ -598,6 +609,7 @@ spec: required: - resource type: object + x-kubernetes-map-type: atomic required: - path type: object @@ -717,6 +729,7 @@ spec: - kind - name type: object + x-kubernetes-map-type: atomic dataSourceRef: description: 'dataSourceRef specifies the object from which to populate the volume with data, if @@ -890,6 +903,7 @@ spec: The requirements are ANDed. type: object type: object + x-kubernetes-map-type: atomic storageClassName: description: 'storageClassName is the name of the StorageClass required by the claim. More info: @@ -980,6 +994,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic required: - driver type: object @@ -1157,6 +1172,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic targetPortal: description: targetPortal is iSCSI Target Portal. The Portal is either an IP or ip_addr:port if the port is other than @@ -1327,6 +1343,7 @@ spec: or its keys must be defined type: boolean type: object + x-kubernetes-map-type: atomic downwardAPI: description: downwardAPI information about the downwardAPI data to project @@ -1356,6 +1373,7 @@ spec: required: - fieldPath type: object + x-kubernetes-map-type: atomic mode: description: 'Optional: mode bits used to set permissions on this file, must be @@ -1404,6 +1422,7 @@ spec: required: - resource type: object + x-kubernetes-map-type: atomic required: - path type: object @@ -1469,6 +1488,7 @@ spec: Secret or its key must be defined type: boolean type: object + x-kubernetes-map-type: atomic serviceAccountToken: description: serviceAccountToken is information about the serviceAccountToken data to project @@ -1585,6 +1605,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic user: description: 'user is the rados user name. Default is admin. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' @@ -1624,6 +1645,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic sslEnabled: description: sslEnabled Flag enable/disable SSL communication with Gateway, default false @@ -1739,6 +1761,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic volumeName: description: volumeName is the human-readable name of the StorageOS volume. Volume names are only unique within @@ -1867,6 +1890,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic observedGeneration: description: The last generation observed by the controller. format: int64 @@ -1881,9 +1905,3 @@ spec: storage: true subresources: status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: [] - storedVersions: [] diff --git a/config/crd/bases/getporter.org_agentconfigs.yaml b/config/crd/bases/getporter.org_agentconfigs.yaml index 32a7ab58..68808029 100644 --- a/config/crd/bases/getporter.org_agentconfigs.yaml +++ b/config/crd/bases/getporter.org_agentconfigs.yaml @@ -3,8 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.8.0 - creationTimestamp: null + controller-gen.kubebuilder.io/version: v0.12.0 name: agentconfigs.getporter.org spec: group: getporter.org @@ -122,6 +121,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic conditions: description: 'Conditions store a list of states that have been reached. Each condition refers to the status of the ActiveJob Possible conditions @@ -214,9 +214,3 @@ spec: storage: true subresources: status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: [] - storedVersions: [] diff --git a/config/crd/bases/getporter.org_credentialsets.yaml b/config/crd/bases/getporter.org_credentialsets.yaml index 33bca633..12766cb9 100644 --- a/config/crd/bases/getporter.org_credentialsets.yaml +++ b/config/crd/bases/getporter.org_credentialsets.yaml @@ -3,8 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.8.0 - creationTimestamp: null + controller-gen.kubebuilder.io/version: v0.12.0 name: credentialsets.getporter.org spec: group: getporter.org @@ -44,6 +43,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic credentials: description: Credentials list of bundle credentials in the credential set. @@ -95,6 +95,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic conditions: description: 'Conditions store a list of states that have been reached. Each condition refers to the status of the ActiveJob Possible conditions @@ -180,9 +181,3 @@ spec: storage: true subresources: status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: [] - storedVersions: [] diff --git a/config/crd/bases/getporter.org_installationoutputs.yaml b/config/crd/bases/getporter.org_installationoutputs.yaml index 9313529e..8395a4ae 100644 --- a/config/crd/bases/getporter.org_installationoutputs.yaml +++ b/config/crd/bases/getporter.org_installationoutputs.yaml @@ -3,8 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.8.0 - creationTimestamp: null + controller-gen.kubebuilder.io/version: v0.12.0 name: installationoutputs.getporter.org spec: group: getporter.org @@ -156,9 +155,3 @@ spec: storage: true subresources: status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: [] - storedVersions: [] diff --git a/config/crd/bases/getporter.org_installations.yaml b/config/crd/bases/getporter.org_installations.yaml index b132a432..0bab4b08 100644 --- a/config/crd/bases/getporter.org_installations.yaml +++ b/config/crd/bases/getporter.org_installations.yaml @@ -3,8 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.8.0 - creationTimestamp: null + controller-gen.kubebuilder.io/version: v0.12.0 name: installations.getporter.org spec: group: getporter.org @@ -63,6 +62,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic bundle: description: Bundle definition for the installation. properties: @@ -134,6 +134,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic conditions: description: 'Conditions store a list of states that have been reached. Each condition refers to the status of the ActiveJob Possible conditions @@ -219,9 +220,3 @@ spec: storage: true subresources: status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: [] - storedVersions: [] diff --git a/config/crd/bases/getporter.org_parametersets.yaml b/config/crd/bases/getporter.org_parametersets.yaml index 08c531a5..3f338b19 100644 --- a/config/crd/bases/getporter.org_parametersets.yaml +++ b/config/crd/bases/getporter.org_parametersets.yaml @@ -3,8 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.8.0 - creationTimestamp: null + controller-gen.kubebuilder.io/version: v0.12.0 name: parametersets.getporter.org spec: group: getporter.org @@ -44,6 +43,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic name: description: Name is the name of the parameter set in Porter. Immutable. type: string @@ -99,6 +99,7 @@ spec: TODO: Add other useful fields. apiVersion, kind, uid?' type: string type: object + x-kubernetes-map-type: atomic conditions: description: 'Conditions store a list of states that have been reached. Each condition refers to the status of the ActiveJob Possible conditions @@ -184,9 +185,3 @@ spec: storage: true subresources: status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: [] - storedVersions: [] diff --git a/config/crd/bases/getporter.org_porterconfigs.yaml b/config/crd/bases/getporter.org_porterconfigs.yaml index ef921d49..e818b57f 100644 --- a/config/crd/bases/getporter.org_porterconfigs.yaml +++ b/config/crd/bases/getporter.org_porterconfigs.yaml @@ -3,8 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.8.0 - creationTimestamp: null + controller-gen.kubebuilder.io/version: v0.12.0 name: porterconfigs.getporter.org spec: group: getporter.org @@ -138,9 +137,3 @@ spec: storage: true subresources: status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: [] - storedVersions: [] diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 26cd7ad7..861a103b 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -2,7 +2,6 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - creationTimestamp: null name: manager-role rules: - apiGroups: diff --git a/controllers/agentconfig_controller.go b/controllers/agentconfig_controller.go index d0feaa12..49e89b29 100644 --- a/controllers/agentconfig_controller.go +++ b/controllers/agentconfig_controller.go @@ -15,7 +15,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/ptr" - ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" diff --git a/controllers/agentconfig_controller_test.go b/controllers/agentconfig_controller_test.go index c0bc4d50..d70e9b86 100644 --- a/controllers/agentconfig_controller_test.go +++ b/controllers/agentconfig_controller_test.go @@ -19,7 +19,7 @@ import ( utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/utils/ptr" - controllerruntime "sigs.k8s.io/controller-runtime" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" ) @@ -158,7 +158,7 @@ func TestAgentConfigReconciler_Reconcile(t *testing.T) { fullname := types.NamespacedName{Namespace: namespace, Name: testAgentCfg.Name} key := client.ObjectKey{Namespace: namespace, Name: testAgentCfg.Name} - request := controllerruntime.Request{ + request := ctrl.Request{ NamespacedName: fullname, } result, err := controller.Reconcile(ctx, request) @@ -389,7 +389,7 @@ func TestAgentConfigReconciler_AgentConfigUpdates(t *testing.T) { fullname := types.NamespacedName{Namespace: namespace, Name: testAgentCfg.Name} key := client.ObjectKey{Namespace: namespace, Name: testAgentCfg.Name} - request := controllerruntime.Request{ + request := ctrl.Request{ NamespacedName: fullname, } result, err := controller.Reconcile(ctx, request) diff --git a/controllers/credentialset_controller_test.go b/controllers/credentialset_controller_test.go index f7b2bd4c..2dcd8f86 100644 --- a/controllers/credentialset_controller_test.go +++ b/controllers/credentialset_controller_test.go @@ -18,7 +18,7 @@ import ( utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/utils/ptr" - controllerruntime "sigs.k8s.io/controller-runtime" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -57,7 +57,7 @@ func TestCredentialSetReconiler_Reconcile(t *testing.T) { triggerReconcile := func() { fullname := types.NamespacedName{Namespace: namespace, Name: name} key := client.ObjectKey{Namespace: namespace, Name: name} - request := controllerruntime.Request{ + request := ctrl.Request{ NamespacedName: fullname, } result, err := controller.Reconcile(ctx, request) diff --git a/controllers/generate.go b/controllers/generate.go new file mode 100644 index 00000000..e5a36d6a --- /dev/null +++ b/controllers/generate.go @@ -0,0 +1,3 @@ +package controllers + +//go:generate mockery --name=PorterClient --filename=grpc_mocks.go --outpkg=mocks --output=../mocks/grpc diff --git a/controllers/installation_controller.go b/controllers/installation_controller.go index 9bbc250f..8224d292 100644 --- a/controllers/installation_controller.go +++ b/controllers/installation_controller.go @@ -2,15 +2,21 @@ package controllers import ( "context" + "fmt" "reflect" + "strings" + "time" - porterv1 "get.porter.sh/operator/api/v1" + v1 "get.porter.sh/operator/api/v1" + installationv1 "get.porter.sh/porter/gen/proto/go/porterapis/installation/v1alpha1" "github.com/go-logr/logr" "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" @@ -24,8 +30,9 @@ const ( // InstallationReconciler calls porter to execute changes made to an Installation CRD type InstallationReconciler struct { client.Client - Log logr.Logger - Scheme *runtime.Scheme + Log logr.Logger + PorterGRPCClient PorterClient + Scheme *runtime.Scheme } // +kubebuilder:rbac:groups=getporter.org,resources=agentconfigs,verbs=get;list;watch;create;update;patch;delete @@ -42,8 +49,9 @@ type InstallationReconciler struct { // SetupWithManager sets up the controller with the Manager. func (r *InstallationReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&porterv1.Installation{}, builder.WithPredicates(resourceChanged{})). - Owns(&porterv1.AgentAction{}). + For(&v1.Installation{}, builder.WithPredicates(resourceChanged{})). + Owns(&v1.AgentAction{}). + Owns(&v1.InstallationOutput{}, builder.MatchEveryOwner). Complete(r) } @@ -54,7 +62,7 @@ func (r *InstallationReconciler) Reconcile(ctx context.Context, req ctrl.Request log := r.Log.WithValues("installation", req.Name, "namespace", req.Namespace) // Retrieve the Installation - inst := &porterv1.Installation{} + inst := &v1.Installation{} err := r.Get(ctx, req.NamespacedName, inst) if err != nil { if apierrors.IsNotFound(err) { @@ -65,8 +73,8 @@ func (r *InstallationReconciler) Reconcile(ctx context.Context, req ctrl.Request } if inst.DeletionTimestamp != nil { - if controllerutil.ContainsFinalizer(inst, porterv1.FinalizerName) { - controllerutil.RemoveFinalizer(inst, porterv1.FinalizerName) + if controllerutil.ContainsFinalizer(inst, v1.FinalizerName) { + controllerutil.RemoveFinalizer(inst, v1.FinalizerName) if err := r.Update(ctx, inst); err != nil { return ctrl.Result{}, err } @@ -75,8 +83,10 @@ func (r *InstallationReconciler) Reconcile(ctx context.Context, req ctrl.Request log = log.WithValues("resourceVersion", inst.ResourceVersion, "generation", inst.Generation, "observedGeneration", inst.Status.ObservedGeneration) log.V(Log5Trace).Info("Reconciling installation") - // Check if we have requested an agent run yet + // TODO Look for annoation/label for outputs generation CR + // TODO Get installationoutput CR if annotation exists + action, handled, err := r.isHandled(ctx, log, inst) if err != nil { return ctrl.Result{}, err @@ -109,6 +119,9 @@ func (r *InstallationReconciler) Reconcile(ctx context.Context, req ctrl.Request // Nothing for us to do at this point log.V(Log4Debug).Info("Reconciliation complete: A porter agent has already been dispatched.") + if r.PorterGRPCClient != nil { + return r.CheckOrCreateInstallationOutputsCR(ctx, log, inst) + } return ctrl.Result{}, nil } @@ -143,13 +156,113 @@ func (r *InstallationReconciler) Reconcile(ctx context.Context, req ctrl.Request } log.V(Log4Debug).Info("Reconciliation complete: A porter agent has been dispatched to apply changes to the installation.") + if r.PorterGRPCClient != nil { + return r.CheckOrCreateInstallationOutputsCR(ctx, log, inst) + } return ctrl.Result{}, nil } +func (r *InstallationReconciler) CheckOrCreateInstallationOutputsCR(ctx context.Context, log logr.Logger, inst *v1.Installation) (ctrl.Result, error) { + // NOTE: May not want to requeue if this fails + installCr := &v1.InstallationOutput{} + err := r.Get(ctx, types.NamespacedName{Name: inst.Spec.Name, Namespace: inst.Spec.Namespace}, installCr) + if err != nil { + if apierrors.IsNotFound(err) { + log.V(Log4Debug).Info("installation output cr doesn't exist, seeing if we should create") + in := &installationv1.ListInstallationLatestOutputRequest{Name: inst.Spec.Name, Namespace: ptr.To(inst.Spec.Namespace)} + resp, err := r.PorterGRPCClient.ListInstallationLatestOutputs(ctx, in) + if err != nil { + log.V(Log4Debug).Info(fmt.Sprintf("failed to get output from grpc server for: %s:%s installation error: %s", inst.Spec.Name, inst.Spec.Namespace, err.Error())) + // NOTE: Stop installation output cr creation + return ctrl.Result{}, nil + } + // TODO: Separate this into it's own func to test and extract what you + // can + log.V(Log5Trace).Info("creating installation outputs cr") + outputs, err := r.CreateInstallationOutputsCR(ctx, inst, resp) + if err != nil { + log.V(Log4Debug).Error(err, "error creating installation outputs resource") + return ctrl.Result{}, err + } + // TODO: Wrap in a retry? Try to reduce the errors + log.V(Log5Trace).Info("setting owner references on outputs cr") + controllerutil.SetOwnerReference(inst, outputs, r.Scheme) + err = r.Create(ctx, outputs, &client.CreateOptions{}) + if err != nil { + return ctrl.Result{}, err + } + installOutputs, err := r.CreateStatusOutputs(ctx, outputs, resp) + if err != nil { + return ctrl.Result{}, err + } + + err = r.Status().Update(ctx, installOutputs) + if err != nil { + return ctrl.Result{}, err + } + log.V(Log5Trace).Info("successfully created outputs cr") + patchInstall := client.MergeFrom(inst.DeepCopy()) + inst.SetAnnotations(map[string]string{v1.AnnotationInstallationOutput: "true"}) + log.V(Log5Trace).Info("patching installation cr") + return ctrl.Result{}, r.Patch(ctx, inst, patchInstall) + } + } + patchInstallCR := client.MergeFrom(installCr.DeepCopy()) + return ctrl.Result{}, r.Patch(ctx, installCr, patchInstallCR) +} +func (r *InstallationReconciler) CreateStatusOutputs(ctx context.Context, install *v1.InstallationOutput, in *installationv1.ListInstallationLatestOutputResponse) (*v1.InstallationOutput, error) { + install.Status = v1.InstallationOutputStatus{ + Phase: v1.PhaseSucceeded, + Conditions: []metav1.Condition{ + { + Type: v1.InstallationOutputSucceeded, + Status: metav1.ConditionTrue, + Reason: "InstallationOutputCreatedSuccess", + LastTransitionTime: metav1.NewTime(time.Now()), + Message: "outputs custom resource generated succeeded", + }, + }, + } + + outputs := []v1.Output{} + outputNames := []string{} + for _, output := range in.Outputs { + tmpOutput := v1.Output{ + Name: output.Name, + Type: output.Type, + Sensitive: output.Sensitive, + Value: output.GetValue().GetStringValue(), + } + outputNames = append(outputNames, output.Name) + outputs = append(outputs, tmpOutput) + } + install.Status.Outputs = outputs + install.Status.OutputNames = strings.Join(outputNames, ",") + + return install, nil +} + +func (r *InstallationReconciler) CreateInstallationOutputsCR(ctx context.Context, install *v1.Installation, in *installationv1.ListInstallationLatestOutputResponse) (*v1.InstallationOutput, error) { + if len(in.Outputs) < 1 { + return nil, fmt.Errorf("no outputs for the installation %s", install.Name) + } + installOutputs := &v1.InstallationOutput{ + ObjectMeta: metav1.ObjectMeta{ + Name: install.Spec.Name, + Namespace: install.Namespace, + }, + Spec: v1.InstallationOutputSpec{ + Name: install.Spec.Name, + Namespace: install.Spec.Namespace, + }, + } + return installOutputs, nil +} + // Determines if this generation of the Installation has being processed by Porter. -func (r *InstallationReconciler) isHandled(ctx context.Context, log logr.Logger, inst *porterv1.Installation) (*porterv1.AgentAction, bool, error) { +func (r *InstallationReconciler) isHandled(ctx context.Context, log logr.Logger, inst *v1.Installation) (*v1.AgentAction, bool, error) { labels := getActionLabels(inst) - results := porterv1.AgentActionList{} + results := v1.AgentActionList{} err := r.List(ctx, &results, client.InNamespace(inst.Namespace), client.MatchingLabels(labels)) if err != nil { return nil, false, errors.Wrapf(err, "could not query for the current agent action") @@ -165,7 +278,7 @@ func (r *InstallationReconciler) isHandled(ctx context.Context, log logr.Logger, } // Run the porter agent with the command `porter installation apply` -func (r *InstallationReconciler) applyInstallation(ctx context.Context, log logr.Logger, inst *porterv1.Installation) error { +func (r *InstallationReconciler) applyInstallation(ctx context.Context, log logr.Logger, inst *v1.Installation) error { log.V(Log5Trace).Info("Initializing installation status") inst.Status.Initialize() if err := r.saveStatus(ctx, log, inst); err != nil { @@ -176,7 +289,7 @@ func (r *InstallationReconciler) applyInstallation(ctx context.Context, log logr } // Flag the bundle as uninstalled, and then run the porter agent with the command `porter installation apply` -func (r *InstallationReconciler) uninstallInstallation(ctx context.Context, log logr.Logger, inst *porterv1.Installation) error { +func (r *InstallationReconciler) uninstallInstallation(ctx context.Context, log logr.Logger, inst *v1.Installation) error { log.V(Log5Trace).Info("Initializing installation status") inst.Status.Initialize() if err := r.saveStatus(ctx, log, inst); err != nil { @@ -191,7 +304,7 @@ func (r *InstallationReconciler) uninstallInstallation(ctx context.Context, log } // Trigger an agent -func (r *InstallationReconciler) runPorter(ctx context.Context, log logr.Logger, inst *porterv1.Installation) error { +func (r *InstallationReconciler) runPorter(ctx context.Context, log logr.Logger, inst *v1.Installation) error { action, err := r.createAgentAction(ctx, log, inst) if err != nil { return err @@ -202,7 +315,7 @@ func (r *InstallationReconciler) runPorter(ctx context.Context, log logr.Logger, } // create an AgentAction that will trigger running porter -func (r *InstallationReconciler) createAgentAction(ctx context.Context, log logr.Logger, inst *porterv1.Installation) (*porterv1.AgentAction, error) { +func (r *InstallationReconciler) createAgentAction(ctx context.Context, log logr.Logger, inst *v1.Installation) (*v1.AgentAction, error) { log.V(Log5Trace).Info("Creating porter agent action") installationResourceB, err := inst.Spec.ToPorterDocument() @@ -215,14 +328,14 @@ func (r *InstallationReconciler) createAgentAction(ctx context.Context, log logr labels[k] = v } - action := &porterv1.AgentAction{ + action := &v1.AgentAction{ ObjectMeta: metav1.ObjectMeta{ Namespace: inst.Namespace, GenerateName: inst.Name + "-", Labels: labels, Annotations: inst.Annotations, }, - Spec: porterv1.AgentActionSpec{ + Spec: v1.AgentActionSpec{ AgentConfig: inst.Spec.AgentConfig, Args: []string{"installation", "apply", "installation.yaml"}, Files: map[string][]byte{ @@ -230,7 +343,6 @@ func (r *InstallationReconciler) createAgentAction(ctx context.Context, log logr }, }, } - if err := controllerutil.SetControllerReference(inst, action, r.Scheme); err != nil { return nil, err } @@ -243,7 +355,7 @@ func (r *InstallationReconciler) createAgentAction(ctx context.Context, log logr } // Check the status of the porter-agent job and use that to update the AgentAction status -func (r *InstallationReconciler) syncStatus(ctx context.Context, log logr.Logger, inst *porterv1.Installation, action *porterv1.AgentAction) error { +func (r *InstallationReconciler) syncStatus(ctx context.Context, log logr.Logger, inst *v1.Installation, action *v1.AgentAction) error { origStatus := inst.Status applyAgentAction(log, inst, action) @@ -256,20 +368,20 @@ func (r *InstallationReconciler) syncStatus(ctx context.Context, log logr.Logger } // Only update the status with a PATCH, don't clobber the entire installation -func (r *InstallationReconciler) saveStatus(ctx context.Context, log logr.Logger, inst *porterv1.Installation) error { +func (r *InstallationReconciler) saveStatus(ctx context.Context, log logr.Logger, inst *v1.Installation) error { log.V(Log5Trace).Info("Patching installation status") return PatchStatusWithRetry(ctx, log, r.Client, r.Status().Patch, inst, func() client.Object { - return &porterv1.Installation{} + return &v1.Installation{} }) } -func (r *InstallationReconciler) shouldUninstall(inst *porterv1.Installation) bool { +func (r *InstallationReconciler) shouldUninstall(inst *v1.Installation) bool { // ignore a deleted CRD with no finalizers return isDeleted(inst) && isFinalizerSet(inst) } // Sync the retry annotation from the installation to the agent action to trigger another run. -func (r *InstallationReconciler) retry(ctx context.Context, log logr.Logger, inst *porterv1.Installation, action *porterv1.AgentAction) error { +func (r *InstallationReconciler) retry(ctx context.Context, log logr.Logger, inst *v1.Installation, action *v1.AgentAction) error { log.V(Log5Trace).Info("Initializing installation status") inst.Status.Initialize() inst.Status.Action = &corev1.LocalObjectReference{Name: action.Name} diff --git a/controllers/installation_controller_test.go b/controllers/installation_controller_test.go index 8640b689..de4f753b 100644 --- a/controllers/installation_controller_test.go +++ b/controllers/installation_controller_test.go @@ -2,13 +2,17 @@ package controllers import ( "context" + "fmt" "testing" "time" v1 "get.porter.sh/operator/api/v1" + mocks "get.porter.sh/operator/mocks/grpc" + installationv1 "get.porter.sh/porter/gen/proto/go/porterapis/installation/v1alpha1" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" @@ -297,6 +301,162 @@ func TestDeletionTimeStampInstallation(t *testing.T) { assert.NoError(t, err) } +func TestCreateInstallationOutputsCR(t *testing.T) { + ctx := context.Background() + install := &v1.Installation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fake-install", + Namespace: "fake-ns", + }, + Spec: v1.InstallationSpec{ + Name: "install-name", + Namespace: "install-ns", + }, + } + in := &installationv1.ListInstallationLatestOutputResponse{ + Outputs: []*installationv1.PorterValue{ + { + Name: "fake-output", + Type: "string", + Sensitive: false, + Value: structpb.NewStringValue("this is an output"), + }, + }, + } + tests := map[string]struct { + wantError bool + outputs *installationv1.ListInstallationLatestOutputResponse + }{ + "success": {wantError: false, outputs: in}, + "failure": {wantError: true, outputs: &installationv1.ListInstallationLatestOutputResponse{}}, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + rec := setupInstallationController() + cr, err := rec.CreateInstallationOutputsCR(ctx, install, test.outputs) + if test.wantError { + assert.Error(t, err) + } + if !test.wantError { + assert.NoError(t, err) + assert.IsType(t, &v1.InstallationOutput{}, cr) + } + }) + } +} + +func TestCreateStatusOutputs(t *testing.T) { + ctx := context.Background() + rec := setupInstallationController() + install := &v1.InstallationOutput{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fake-name", + Namespace: "fake-ns", + }, + Spec: v1.InstallationOutputSpec{ + Name: "fake-porterName", + Namespace: "fake-porter-namespace", + }, + } + in := &installationv1.ListInstallationLatestOutputResponse{ + Outputs: []*installationv1.PorterValue{ + { + Name: "fake-output", + Type: "string", + Sensitive: false, + Value: structpb.NewStringValue("this is an output"), + }, + }, + } + installOut, err := rec.CreateStatusOutputs(ctx, install, in) + assert.NoError(t, err) + assert.IsType(t, v1.InstallationOutputStatus{}, installOut.Status) +} + +func TestCheckOrCreateInstallationOutputsCR(t *testing.T) { + ctx := context.Background() + output := &v1.InstallationOutput{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fake-install", + Namespace: "fake-ns", + }, + } + install := &v1.Installation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fake-install", + Namespace: "fake-ns", + }, + Spec: v1.InstallationSpec{ + Name: "fake-install", + Namespace: "fake-ns", + }, + } + rec := setupInstallationController(output) + _, err := rec.CheckOrCreateInstallationOutputsCR(ctx, logr.Discard(), install) + assert.NoError(t, err) +} + +func TestCheckOrCreateInstallationOutputsCRCreate(t *testing.T) { + ctx := context.Background() + grpcClient := &mocks.PorterClient{} + outputs := &installationv1.ListInstallationLatestOutputResponse{ + Outputs: []*installationv1.PorterValue{ + { + Name: "fake-output", + Type: "string", + Sensitive: false, + Value: structpb.NewStringValue("output that is fake"), + }, + }, + } + listInstallationRequest := &installationv1.ListInstallationLatestOutputRequest{Name: "fake-install", Namespace: ptr.To("fake-ns")} + grpcClient.On("ListInstallationLatestOutputs", ctx, listInstallationRequest).Return(outputs, nil) + rec := setupInstallationController() + rec.PorterGRPCClient = grpcClient + install := &v1.Installation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fake-install", + Namespace: "fake-ns", + }, + Spec: v1.InstallationSpec{ + Name: "fake-install", + Namespace: "fake-ns", + }, + } + _, err := rec.CheckOrCreateInstallationOutputsCR(ctx, logr.Discard(), install) + // NOTE: This errors because of the limitation we have with fake in + // controller-runtime. https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.15.0/pkg/client/fake + // There is some support for sub resources which + // can cause issues with tests if you're trying to update e.g. + // metadata and status in the same reconcile. We update the status in the + // same reconcile when creating the object but it can't find it after it + // creates it. This limitation isn't an issue when running live. + assert.Error(t, err) +} + +func TestCheckOrCreateInstallationOutputsCRCreateFail(t *testing.T) { + ctx := context.Background() + grpcClient := &mocks.PorterClient{} + listInstallationRequest := &installationv1.ListInstallationLatestOutputRequest{Name: "fake-install", Namespace: ptr.To("fake-ns")} + grpcClient.On("ListInstallationLatestOutputs", ctx, listInstallationRequest).Return(nil, fmt.Errorf("this is an error")) + rec := setupInstallationController() + rec.PorterGRPCClient = grpcClient + install := &v1.Installation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "fake-install", + Namespace: "fake-ns", + }, + Spec: v1.InstallationSpec{ + Name: "fake-install", + Namespace: "fake-ns", + }, + } + _, err := rec.CheckOrCreateInstallationOutputsCR(ctx, logr.Discard(), install) + // NOTE: This will return nil if the output of the grpc call fails. We do not + // want to requeue if this fails. We will not include outputs of + // installations that do not have it stored in the grpc server. + assert.NoError(t, err) +} func setupInstallationController(objs ...client.Object) *InstallationReconciler { scheme := runtime.NewScheme() utilruntime.Must(clientgoscheme.AddToScheme(scheme)) diff --git a/controllers/parameterset_controller_test.go b/controllers/parameterset_controller_test.go index 11ddc3f1..13a8a002 100644 --- a/controllers/parameterset_controller_test.go +++ b/controllers/parameterset_controller_test.go @@ -18,7 +18,7 @@ import ( utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/utils/ptr" - controllerruntime "sigs.k8s.io/controller-runtime" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -57,7 +57,7 @@ func TestParameterSetReconiler_Reconcile(t *testing.T) { triggerReconcile := func() { fullname := types.NamespacedName{Namespace: namespace, Name: name} key := client.ObjectKey{Namespace: namespace, Name: name} - request := controllerruntime.Request{ + request := ctrl.Request{ NamespacedName: fullname, } result, err := controller.Reconcile(ctx, request) diff --git a/controllers/types.go b/controllers/types.go new file mode 100644 index 00000000..a08542fd --- /dev/null +++ b/controllers/types.go @@ -0,0 +1,13 @@ +package controllers + +import ( + "context" + + installationv1 "get.porter.sh/porter/gen/proto/go/porterapis/installation/v1alpha1" + "google.golang.org/grpc" +) + +type PorterClient interface { + ListInstallations(ctx context.Context, in *installationv1.ListInstallationsRequest, opts ...grpc.CallOption) (*installationv1.ListInstallationsResponse, error) + ListInstallationLatestOutputs(ctx context.Context, in *installationv1.ListInstallationLatestOutputRequest, opts ...grpc.CallOption) (*installationv1.ListInstallationLatestOutputResponse, error) +} diff --git a/go.mod b/go.mod index a537ff48..ae50e93f 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,8 @@ require ( github.com/tidwall/gjson v1.14.4 github.com/tidwall/pretty v1.2.1 golang.org/x/sync v0.3.0 + google.golang.org/grpc v1.56.1 + google.golang.org/protobuf v1.31.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.29.0-alpha.0 k8s.io/apimachinery v0.29.0-alpha.0 @@ -130,6 +132,7 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.14.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect github.com/subosito/gotenv v1.4.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect @@ -169,8 +172,6 @@ require ( google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529 // indirect - google.golang.org/grpc v1.56.1 // indirect - google.golang.org/protobuf v1.31.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 // indirect diff --git a/go.sum b/go.sum index 4e4695b0..02516da7 100644 --- a/go.sum +++ b/go.sum @@ -683,6 +683,7 @@ github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5J github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/main.go b/main.go index 2c00e048..4446410e 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,14 @@ package main import ( + "context" "flag" "os" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/apimachinery/pkg/runtime" @@ -18,6 +21,7 @@ import ( v1 "get.porter.sh/operator/api/v1" "get.porter.sh/operator/controllers" + porterv1alpha1 "get.porter.sh/porter/gen/proto/go/porterapis/porter/v1alpha1" // +kubebuilder:scaffold:imports ) @@ -62,11 +66,21 @@ func main() { setupLog.Error(err, "unable to start manager") os.Exit(1) } - + // NOTE: Pass in nil client if connection isn't established + var client controllers.PorterClient + conn, err := grpc.DialContext(context.Background(), "porter-grpc-service:3001", grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + setupLog.Error(err, "unable to set up listener") + } + defer conn.Close() + if conn != nil { + client = porterv1alpha1.NewPorterClient(conn) + } if err = (&controllers.InstallationReconciler{ - Client: mgr.GetClient(), - Log: ctrl.Log.WithName("controllers").WithName("Installation"), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + PorterGRPCClient: client, + Log: ctrl.Log.WithName("controllers").WithName("Installation"), + Scheme: mgr.GetScheme(), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Installation") os.Exit(1) diff --git a/mocks/grpc/grpc_mocks.go b/mocks/grpc/grpc_mocks.go new file mode 100644 index 00000000..2bf41836 --- /dev/null +++ b/mocks/grpc/grpc_mocks.go @@ -0,0 +1,98 @@ +// Code generated by mockery v2.32.4. DO NOT EDIT. + +package mocks + +import ( + context "context" + + grpc "google.golang.org/grpc" + + installationv1alpha1 "get.porter.sh/porter/gen/proto/go/porterapis/installation/v1alpha1" + + mock "github.com/stretchr/testify/mock" +) + +// PorterClient is an autogenerated mock type for the PorterClient type +type PorterClient struct { + mock.Mock +} + +// ListInstallationLatestOutputs provides a mock function with given fields: ctx, in, opts +func (_m *PorterClient) ListInstallationLatestOutputs(ctx context.Context, in *installationv1alpha1.ListInstallationLatestOutputRequest, opts ...grpc.CallOption) (*installationv1alpha1.ListInstallationLatestOutputResponse, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, in) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *installationv1alpha1.ListInstallationLatestOutputResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *installationv1alpha1.ListInstallationLatestOutputRequest, ...grpc.CallOption) (*installationv1alpha1.ListInstallationLatestOutputResponse, error)); ok { + return rf(ctx, in, opts...) + } + if rf, ok := ret.Get(0).(func(context.Context, *installationv1alpha1.ListInstallationLatestOutputRequest, ...grpc.CallOption) *installationv1alpha1.ListInstallationLatestOutputResponse); ok { + r0 = rf(ctx, in, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*installationv1alpha1.ListInstallationLatestOutputResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *installationv1alpha1.ListInstallationLatestOutputRequest, ...grpc.CallOption) error); ok { + r1 = rf(ctx, in, opts...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListInstallations provides a mock function with given fields: ctx, in, opts +func (_m *PorterClient) ListInstallations(ctx context.Context, in *installationv1alpha1.ListInstallationsRequest, opts ...grpc.CallOption) (*installationv1alpha1.ListInstallationsResponse, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, in) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *installationv1alpha1.ListInstallationsResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *installationv1alpha1.ListInstallationsRequest, ...grpc.CallOption) (*installationv1alpha1.ListInstallationsResponse, error)); ok { + return rf(ctx, in, opts...) + } + if rf, ok := ret.Get(0).(func(context.Context, *installationv1alpha1.ListInstallationsRequest, ...grpc.CallOption) *installationv1alpha1.ListInstallationsResponse); ok { + r0 = rf(ctx, in, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*installationv1alpha1.ListInstallationsResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *installationv1alpha1.ListInstallationsRequest, ...grpc.CallOption) error); ok { + r1 = rf(ctx, in, opts...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewPorterClient creates a new instance of PorterClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPorterClient(t interface { + mock.TestingT + Cleanup(func()) +}) *PorterClient { + mock := &PorterClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +}