Repository: grafana/k6-operator
Pull request #238 (2023-05-24 09:47:51)

Core implementation for PLZ API
Issue: #227 

This code was written for quick prototyping; i.e. it can be improved and refactored more.

Complete implementation of PLZ is split up into 2 parts, this PR being the 1st one. This fact also implies that PLZ from this PR cannot be used yet. The changes here only lay the groundwork for the 2nd PR, with complete logic of PLZ, without breaking existing functionality of the operator.


Patch:
diff --git a/PROJECT b/PROJECT
index fead1be..83512d8 100644
--- a/PROJECT
+++ b/PROJECT
@@ -1,15 +1,28 @@
+# Code generated by tool. DO NOT EDIT.
+# This file is used to track the info used to scaffold your project
+# and allow the plugins properly work.
+# More info: https://book.kubebuilder.io/reference/project-config.html
 domain: io
-layout: go.kubebuilder.io/v2
+layout:
+- go.kubebuilder.io/v2
+plugins:
+  go.sdk.operatorframework.io/v2-alpha: {}
 projectName: k6-operator
 repo: github.com/grafana/k6-operator
 resources:
--
-  controller: true
+- controller: true
   domain: io
   group: k6
   kind: K6
   path: github.com/grafana/k6-operator/api/v1alpha1
   version: v1alpha1
+- api:
+    crdVersion: v1beta1
+    namespaced: true
+  controller: true
+  domain: io
+  group: k6
+  kind: PrivateLoadZone
+  path: github.com/grafana/k6-operator/api/v1alpha1
+  version: v1alpha1
 version: "3"
-plugins:
-  go.sdk.operatorframework.io/v2-alpha: {}
diff --git a/api/v1alpha1/k6_types.go b/api/v1alpha1/k6_types.go
index 7d429f7..8d311ed 100644
--- a/api/v1alpha1/k6_types.go
+++ b/api/v1alpha1/k6_types.go
@@ -15,6 +15,10 @@ limitations under the License.
 package v1alpha1
 
 import (
+	"errors"
+	"path/filepath"
+
+	"github.com/grafana/k6-operator/pkg/types"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
@@ -79,6 +83,9 @@ type K6Spec struct {
 	Paused      string                 `json:"paused,omitempty"`
 	Scuttle     K6Scuttle              `json:"scuttle,omitempty"`
 	Cleanup     Cleanup                `json:"cleanup,omitempty"`
+
+	TestRunID string `json:"testRunId,omitempty"` // PLZ reserved field
+	Token     string `json:"token,omitempty"`     // PLZ reserved field (for now)
 }
 
 // K6Script describes where the script to execute the tests is found
@@ -144,3 +151,41 @@ type K6List struct {
 func init() {
 	SchemeBuilder.Register(&K6{}, &K6List{})
 }
+
+// Parse extracts Script data bits from K6 spec and performs basic validation
+func (spec K6Script) Parse() (*types.Script, error) {
+	s := &types.Script{
+		Filename: "test.js",
+		Path:     "/test/",
+	}
+
+	if spec.VolumeClaim.Name != "" {
+		s.Name = spec.VolumeClaim.Name
+		if spec.VolumeClaim.File != "" {
+			s.Filename = spec.VolumeClaim.File
+		}
+
+		s.Type = "VolumeClaim"
+		return s, nil
+	}
+
+	if spec.ConfigMap.Name != "" {
+		s.Name = spec.ConfigMap.Name
+
+		if spec.ConfigMap.File != "" {
+			s.Filename = spec.ConfigMap.File
+		}
+
+		s.Type = "ConfigMap"
+		return s, nil
+	}
+
+	if spec.LocalFile != "" {
+		s.Name = "LocalFile"
+		s.Type = "LocalFile"
+		s.Path, s.Filename = filepath.Split(spec.LocalFile)
+		return s, nil
+	}
+
+	return nil, errors.New("Script definition should contain one of: ConfigMap, VolumeClaim, LocalFile")
+}
diff --git a/api/v1alpha1/conditions.go b/api/v1alpha1/k6conditions.go
similarity index 58%
rename from api/v1alpha1/conditions.go
rename to api/v1alpha1/k6conditions.go
index a73b4e5..8e5c799 100644
--- a/api/v1alpha1/conditions.go
+++ b/api/v1alpha1/k6conditions.go
@@ -1,10 +1,10 @@
 package v1alpha1
 
 import (
-	"fmt"
 	"strings"
 	"time"
 
+	"github.com/grafana/k6-operator/pkg/types"
 	"k8s.io/apimachinery/pkg/api/meta"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
@@ -35,28 +35,17 @@ const (
 	// - if False, it's a cloud test run and it is yet to be finalized
 	// - if True, it's a cloud test run that has been finalized already
 	CloudTestRunFinalized = "CloudTestRunFinalized"
-)
-
-var reasons = map[string]string{
-	"TestRunRunningUnknown": "TestRunPreparation",
-	"TestRunRunningTrue":    "TestRunRunningTrue",
-	"TestRunRunningFalse":   "TestRunRunningFalse",
-
-	"CloudTestRunUnknown": "TestRunTypeUnknown",
-	"CloudTestRunTrue":    "CloudTestRunTrue",
-	"CloudTestRunFalse":   "CloudTestRunFalse",
 
-	"CloudTestRunCreatedUnknown": "CloudTestRunCreatedUnknown",
-	"CloudTestRunCreatedTrue":    "CloudTestRunCreatedTrue",
-	"CloudTestRunCreatedFalse":   "CloudTestRunCreatedFalse",
-
-	"CloudTestRunFinalizedUnknown": "CloudTestRunFinalizedUnknown",
-	"CloudTestRunFinalizedTrue":    "CloudTestRunFinalizedTrue",
-	"CloudTestRunFinalizedFalse":   "CloudTestRunFinalizedFalse",
-}
+	// CloudPLZTestRun indicates if this k6 Cloud test run is a PLZ test run.
+	// This condition is valid only if CloudTestRun is True as well.
+	// - if empty / Unknown, it's either a non-PLZ test run or it's unknown yet.
+	// - if False, it's not a PLZ test run.
+	// - if True, it is a PLZ test run.
+	CloudPLZTestRun = "CloudPLZTestRun"
+)
 
-// InitializeConditions defines only conditions common to all test runs.
-func (k6 *K6) InitializeConditions() {
+// Initialize defines only conditions common to all test runs.
+func (k6 *K6) Initialize() {
 	t := metav1.Now()
 	k6.Status.Conditions = []metav1.Condition{
 		metav1.Condition{
@@ -74,20 +63,23 @@ func (k6 *K6) InitializeConditions() {
 			Message:            "",
 		},
 	}
+
+	// PLZ test run case
+	if len(k6.Spec.TestRunID) > 0 {
+		k6.UpdateCondition(CloudTestRun, metav1.ConditionTrue)
+		k6.UpdateCondition(CloudPLZTestRun, metav1.ConditionTrue)
+		k6.UpdateCondition(CloudTestRunCreated, metav1.ConditionTrue)
+
+		k6.Status.TestRunID = k6.Spec.TestRunID
+	} else {
+		k6.UpdateCondition(CloudPLZTestRun, metav1.ConditionFalse)
+		// PLZ test run can be defined only via spec.testRunId;
+		// otherwise it's not a PLZ test run.
+	}
 }
 
 func (k6 *K6) UpdateCondition(conditionType string, conditionStatus metav1.ConditionStatus) {
-	reason, ok := reasons[conditionType+string(conditionStatus)]
-	if !ok {
-		panic(fmt.Sprintf("Invalid condition type and status! `%s` - this should never happen!", conditionType+string(conditionStatus)))
-	}
-	meta.SetStatusCondition(&k6.Status.Conditions, metav1.Condition{
-		Type:               conditionType,
-		Status:             conditionStatus,
-		LastTransitionTime: metav1.Now(),
-		Reason:             reason,
-		Message:            "",
-	})
+	types.UpdateCondition(&k6.Status.Conditions, conditionType, conditionStatus)
 }
 
 func (k6 K6) IsTrue(conditionType string) bool {
@@ -114,46 +106,25 @@ func (k6 K6) LastUpdate(conditionType string) (time.Time, bool) {
 // with the expected progression of a test run. If there were any acceptable
 // changes proposed, it returns true.
 func (k6status *K6Status) SetIfNewer(proposedStatus K6Status) (isNewer bool) {
-	existingConditions := map[string]metav1.Condition{}
-	for i := range k6status.Conditions {
-		existingConditions[k6status.Conditions[i].Type] = k6status.Conditions[i]
-	}
-
-	for _, proposedCondition := range proposedStatus.Conditions {
-		// If a new condition is being proposed, just add it to the list.
-		if existingCondition, ok := existingConditions[proposedCondition.Type]; !ok {
-			k6status.Conditions = append(k6status.Conditions, proposedCondition)
-			isNewer = true
-		} else {
-			// If a change in existing condition is being proposed, check if
-			// its timestamp is later than the one in existing condition.
-			//
-			// Additionally: condition should never return to Unknown status
-			// unless it's newly created.
-
-			if proposedCondition.Status != metav1.ConditionUnknown {
-				if existingCondition.LastTransitionTime.UnixNano() < proposedCondition.LastTransitionTime.UnixNano() {
-					meta.SetStatusCondition(&k6status.Conditions, proposedCondition)
-					isNewer = true
-				}
+	isNewer = types.SetIfNewer(&k6status.Conditions, proposedStatus.Conditions,
+		func(proposedCondition metav1.Condition) (isNewer bool) {
+			// Accept change of test run ID only if it's not set yet and together with
+			// corresponding condition.
+			if proposedCondition.Type == CloudTestRunCreated &&
+				len(k6status.TestRunID) == 0 &&
+				len(proposedStatus.TestRunID) > 0 {
+				k6status.TestRunID = proposedStatus.TestRunID
+				isNewer = true
 			}
-		}
+			// log if proposedStatus.TestRunID is empty here?
 
-		// Accept change of test run ID only if it's not set yet and together with
-		// corresponding condition.
-		if proposedCondition.Type == CloudTestRunCreated &&
-			len(k6status.TestRunID) == 0 &&
-			len(proposedStatus.TestRunID) > 0 {
-			k6status.TestRunID = proposedStatus.TestRunID
-			isNewer = true
-		}
-		// log if proposedStatus.TestRunID is empty here?
+			// similarly with aggregation vars
+			if len(proposedStatus.AggregationVars) > 0 && len(k6status.AggregationVars) == 0 {
+				k6status.AggregationVars = proposedStatus.AggregationVars
+			}
 
-		// similarly with aggregation vars
-		if len(proposedStatus.AggregationVars) > 0 && len(k6status.AggregationVars) == 0 {
-			k6status.AggregationVars = proposedStatus.AggregationVars
-		}
-	}
+			return
+		})
 
 	// If a change in stage is proposed, confirm that it is consistent with
 	// expected flow of any test run.
diff --git a/api/v1alpha1/plzconditions.go b/api/v1alpha1/plzconditions.go
new file mode 100644
index 0000000..8444da0
--- /dev/null
+++ b/api/v1alpha1/plzconditions.go
@@ -0,0 +1,48 @@
+package v1alpha1
+
+import (
+	"github.com/grafana/k6-operator/pkg/types"
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+const (
+	// PLZRegistered indicates if the PLZ has been registered.
+	// - if empty / Unknown / False, call registration
+	// - if True, do nothing
+	PLZRegistered = "PLZRegistered"
+)
+
+func (plz *PrivateLoadZone) Initialize() {
+	t := metav1.Now()
+	plz.Status.Conditions = []metav1.Condition{
+		metav1.Condition{
+			Type:               PLZRegistered,
+			Status:             metav1.ConditionUnknown,
+			LastTransitionTime: t,
+			Reason:             "PLZRegisteredUnknown",
+			Message:            "",
+		},
+	}
+}
+func (plz PrivateLoadZone) IsTrue(conditionType string) bool {
+	return meta.IsStatusConditionTrue(plz.Status.Conditions, conditionType)
+}
+
+func (plz PrivateLoadZone) IsFalse(conditionType string) bool {
+	return meta.IsStatusConditionFalse(plz.Status.Conditions, conditionType)
+}
+
+func (plz PrivateLoadZone) IsUnknown(conditionType string) bool {
+	return !plz.IsFalse(conditionType) && !plz.IsTrue(conditionType)
+}
+
+func (plz PrivateLoadZone) UpdateCondition(conditionType string, conditionStatus metav1.ConditionStatus) {
+	types.UpdateCondition(&plz.Status.Conditions, conditionType, conditionStatus)
+}
+
+// SetIfNewer changes plzstatus only if changes in proposedStatus are newer.
+// If there were any acceptable changes proposed, it returns true.
+func (plzStatus *PrivateLoadZoneStatus) SetIfNewer(proposedStatus PrivateLoadZoneStatus) (isNewer bool) {
+	return types.SetIfNewer(&plzStatus.Conditions, proposedStatus.Conditions, nil)
+}
diff --git a/api/v1alpha1/privateloadzone_types.go b/api/v1alpha1/privateloadzone_types.go
new file mode 100644
index 0000000..1576bea
--- /dev/null
+++ b/api/v1alpha1/privateloadzone_types.go
@@ -0,0 +1,84 @@
+/*
+
+
+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 v1alpha1
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/go-logr/logr"
+	"go.k6.io/k6/cloudapi"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// PrivateLoadZoneSpec defines the desired state of PrivateLoadZone
+type PrivateLoadZoneSpec struct {
+	Token              string              `json:"token"`
+	Resources          corev1.ResourceList `json:"resources"`
+	ServiceAccountName string              `json:"serviceAccountName,omitempty"`
+	NodeSelector       map[string]string   `json:"nodeSelector,omitempty"`
+}
+
+// PrivateLoadZoneStatus defines the observed state of PrivateLoadZone
+type PrivateLoadZoneStatus struct {
+	Conditions []metav1.Condition `json:"conditions,omitempty"`
+}
+
+//+kubebuilder:object:root=true
+//+kubebuilder:subresource:status
+
+// PrivateLoadZone is the Schema for the privateloadzones API
+type PrivateLoadZone struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   PrivateLoadZoneSpec   `json:"spec,omitempty"`
+	Status PrivateLoadZoneStatus `json:"status,omitempty"`
+}
+
+//+kubebuilder:object:root=true
+
+// PrivateLoadZoneList contains a list of PrivateLoadZone
+type PrivateLoadZoneList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []PrivateLoadZone `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&PrivateLoadZone{}, &PrivateLoadZoneList{})
+}
+
+// Register attempts to register PLZ with the k6 Cloud.
+// Regardless of the result, condition PLZRegistered will be updated.
+func (plz *PrivateLoadZone) Register(ctx context.Context, logger logr.Logger, client *cloudapi.Client) {
+	plz.UpdateCondition(PLZRegistered, metav1.ConditionFalse)
+
+	// TODO add register call and error processing
+
+	plz.UpdateCondition(PLZRegistered, metav1.ConditionTrue)
+}
+
+// Deregister attempts to deregister PLZ with the k6 Cloud.
+// It is meant to be used as a finalizer.
+func (plz *PrivateLoadZone) Deregister(ctx context.Context, logger logr.Logger, client *cloudapi.Client) {
+	// TODO add deregister call and error processing
+
+	fmt.Println("calling deregister for", *plz)
+	plz.UpdateCondition(PLZRegistered, metav1.ConditionFalse)
+}
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 819d727..add754c 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -339,3 +339,113 @@ func (in *PodMetadata) DeepCopy() *PodMetadata {
 	in.DeepCopyInto(out)
 	return out
 }
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PrivateLoadZone) DeepCopyInto(out *PrivateLoadZone) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrivateLoadZone.
+func (in *PrivateLoadZone) DeepCopy() *PrivateLoadZone {
+	if in == nil {
+		return nil
+	}
+	out := new(PrivateLoadZone)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *PrivateLoadZone) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PrivateLoadZoneList) DeepCopyInto(out *PrivateLoadZoneList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]PrivateLoadZone, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrivateLoadZoneList.
+func (in *PrivateLoadZoneList) DeepCopy() *PrivateLoadZoneList {
+	if in == nil {
+		return nil
+	}
+	out := new(PrivateLoadZoneList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *PrivateLoadZoneList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PrivateLoadZoneSpec) DeepCopyInto(out *PrivateLoadZoneSpec) {
+	*out = *in
+	if in.Resources != nil {
+		in, out := &in.Resources, &out.Resources
+		*out = make(v1.ResourceList, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val.DeepCopy()
+		}
+	}
+	if in.NodeSelector != nil {
+		in, out := &in.NodeSelector, &out.NodeSelector
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrivateLoadZoneSpec.
+func (in *PrivateLoadZoneSpec) DeepCopy() *PrivateLoadZoneSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(PrivateLoadZoneSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PrivateLoadZoneStatus) DeepCopyInto(out *PrivateLoadZoneStatus) {
+	*out = *in
+	if in.Conditions != nil {
+		in, out := &in.Conditions, &out.Conditions
+		*out = make([]metav1.Condition, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrivateLoadZoneStatus.
+func (in *PrivateLoadZoneStatus) DeepCopy() *PrivateLoadZoneStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(PrivateLoadZoneStatus)
+	in.DeepCopyInto(out)
+	return out
+}
diff --git a/config/crd/bases/k6.io_k6s.yaml b/config/crd/bases/k6.io_k6s.yaml
index 9ffd0fa..deb0e50 100644
--- a/config/crd/bases/k6.io_k6s.yaml
+++ b/config/crd/bases/k6.io_k6s.yaml
@@ -5467,6 +5467,10 @@ spec:
                       type: object
                     type: array
                 type: object
+              testRunId:
+                type: string
+              token:
+                type: string
             required:
             - parallelism
             - script
diff --git a/config/crd/bases/k6.io_privateloadzones.yaml b/config/crd/bases/k6.io_privateloadzones.yaml
new file mode 100644
index 0000000..ee7b5ec
--- /dev/null
+++ b/config/crd/bases/k6.io_privateloadzones.yaml
@@ -0,0 +1,144 @@
+
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.3.0
+  creationTimestamp: null
+  name: privateloadzones.k6.io
+spec:
+  group: k6.io
+  names:
+    kind: PrivateLoadZone
+    listKind: PrivateLoadZoneList
+    plural: privateloadzones
+    singular: privateloadzone
+  scope: Namespaced
+  versions:
+  - name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: PrivateLoadZone is the Schema for the privateloadzones API
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: PrivateLoadZoneSpec defines the desired state of PrivateLoadZone
+            properties:
+              nodeSelector:
+                additionalProperties:
+                  type: string
+                type: object
+              resources:
+                additionalProperties:
+                  anyOf:
+                  - type: integer
+                  - type: string
+                  pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
+                  x-kubernetes-int-or-string: true
+                description: ResourceList is a set of (resource name, quantity) pairs.
+                type: object
+              serviceAccountName:
+                type: string
+              token:
+                type: string
+            required:
+            - resources
+            - token
+            type: object
+          status:
+            description: PrivateLoadZoneStatus defines the observed state of PrivateLoadZone
+            properties:
+              conditions:
+                items:
+                  description: "Condition contains details for one aspect of the current
+                    state of this API Resource. --- This struct is intended for direct
+                    use as an array at the field path .status.conditions.  For example,
+                    \n \ttype FooStatus struct{ \t    // Represents the observations
+                    of a foo's current state. \t    // Known .status.conditions.type
+                    are: \"Available\", \"Progressing\", and \"Degraded\" \t    //
+                    +patchMergeKey=type \t    // +patchStrategy=merge \t    // +listType=map
+                    \t    // +listMapKey=type \t    Conditions []metav1.Condition
+                    `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\"
+                    protobuf:\"bytes,1,rep,name=conditions\"` \n \t    // other fields
+                    \t}"
+                  properties:
+                    lastTransitionTime:
+                      description: lastTransitionTime is the last time the condition
+                        transitioned from one status to another. This should be when
+                        the underlying condition changed.  If that is not known, then
+                        using the time when the API field changed is acceptable.
+                      format: date-time
+                      type: string
+                    message:
+                      description: message is a human readable message indicating
+                        details about the transition. This may be an empty string.
+                      maxLength: 32768
+                      type: string
+                    observedGeneration:
+                      description: observedGeneration represents the .metadata.generation
+                        that the condition was set based upon. For instance, if .metadata.generation
+                        is currently 12, but the .status.conditions[x].observedGeneration
+                        is 9, the condition is out of date with respect to the current
+                        state of the instance.
+                      format: int64
+                      minimum: 0
+                      type: integer
+                    reason:
+                      description: reason contains a programmatic identifier indicating
+                        the reason for the condition's last transition. Producers
+                        of specific condition types may define expected values and
+                        meanings for this field, and whether the values are considered
+                        a guaranteed API. The value should be a CamelCase string.
+                        This field may not be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    status:
+                      description: status of the condition, one of True, False, Unknown.
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of condition in CamelCase or in foo.example.com/CamelCase.
+                        --- Many .condition.type values are consistent across resources
+                        like Available, but because arbitrary conditions can be useful
+                        (see .node.status.conditions), the ability to deconflict is
+                        important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt)
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - message
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index bf46815..50f49e7 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -4,6 +4,7 @@
 # It should be run by config/default
 resources:
   - bases/k6.io_k6s.yaml
+  - bases/k6.io_privateloadzones.yaml
 # +kubebuilder:scaffold:crdkustomizeresource
 
 patchesStrategicMerge:
@@ -11,12 +12,14 @@ patchesStrategicMerge:
 # patches here are for enabling the conversion webhook for each CRD
 # - patches/webhook_in_k6s.yaml
 # - patches/webhook_in_k6tests.yaml
+#- patches/webhook_in_privateloadzones.yaml
 # +kubebuilder:scaffold:crdkustomizewebhookpatch
 
 # [CERTMANAGER] To enable webhook, uncomment all the sections with [CERTMANAGER] prefix.
 # patches here are for enabling the CA injection for each CRD
 # - patches/cainjection_in_k6s.yaml
 # - patches/cainjection_in_k6tests.yaml
+#- patches/cainjection_in_privateloadzones.yaml
 # +kubebuilder:scaffold:crdkustomizecainjectionpatch
 
 # the following config is for teaching kustomize how to do kustomization for CRDs.
diff --git a/config/crd/patches/cainjection_in_privateloadzones.yaml b/config/crd/patches/cainjection_in_privateloadzones.yaml
new file mode 100644
index 0000000..d379363
--- /dev/null
+++ b/config/crd/patches/cainjection_in_privateloadzones.yaml
@@ -0,0 +1,8 @@
+# The following patch adds a directive for certmanager to inject CA into the CRD
+# CRD conversion requires k8s 1.13 or later.
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME)
+  name: privateloadzones.k6.io
diff --git a/config/crd/patches/webhook_in_privateloadzones.yaml b/config/crd/patches/webhook_in_privateloadzones.yaml
new file mode 100644
index 0000000..d01e439
--- /dev/null
+++ b/config/crd/patches/webhook_in_privateloadzones.yaml
@@ -0,0 +1,17 @@
+# The following patch enables conversion webhook for CRD
+# CRD conversion requires k8s 1.13 or later.
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: privateloadzones.k6.io
+spec:
+  conversion:
+    strategy: Webhook
+    webhookClientConfig:
+      # this is "\n" used as a placeholder, otherwise it will be rejected by the apiserver for being blank,
+      # but we're going to set it later using the cert-manager (or potentially a patch if not using cert-manager)
+      caBundle: Cg==
+      service:
+        namespace: system
+        name: webhook-service
+        path: /convert
diff --git a/config/rbac/privateloadzone_editor_role.yaml b/config/rbac/privateloadzone_editor_role.yaml
new file mode 100644
index 0000000..efc3c58
--- /dev/null
+++ b/config/rbac/privateloadzone_editor_role.yaml
@@ -0,0 +1,24 @@
+# permissions for end users to edit privateloadzones.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: privateloadzone-editor-role
+rules:
+- apiGroups:
+  - k6.io
+  resources:
+  - privateloadzones
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - k6.io
+  resources:
+  - privateloadzones/status
+  verbs:
+  - get
diff --git a/config/rbac/privateloadzone_viewer_role.yaml b/config/rbac/privateloadzone_viewer_role.yaml
new file mode 100644
index 0000000..0d635c0
--- /dev/null
+++ b/config/rbac/privateloadzone_viewer_role.yaml
@@ -0,0 +1,20 @@
+# permissions for end users to view privateloadzones.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: privateloadzone-viewer-role
+rules:
+- apiGroups:
+  - k6.io
+  resources:
+  - privateloadzones
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - k6.io
+  resources:
+  - privateloadzones/status
+  verbs:
+  - get
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index ae31bf4..c9ab65b 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -88,3 +88,31 @@ rules:
   - get
   - patch
   - update
+- apiGroups:
+  - k6.io
+  resources:
+  - privateloadzones
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - k6.io
+  resources:
+  - privateloadzones/finalizers
+  verbs:
+  - get
+  - patch
+  - update
+- apiGroups:
+  - k6.io
+  resources:
+  - privateloadzones/status
+  verbs:
+  - get
+  - patch
+  - update
diff --git a/config/samples/k6_v1alpha1_privateloadzone.yaml b/config/samples/k6_v1alpha1_privateloadzone.yaml
new file mode 100644
index 0000000..139f069
--- /dev/null
+++ b/config/samples/k6_v1alpha1_privateloadzone.yaml
@@ -0,0 +1,10 @@
+apiVersion: k6.io/v1alpha1
+kind: PrivateLoadZone
+metadata:
+  name: privateloadzone-sample
+  namespace: foo
+spec:
+  token: grafana-k6-token
+  resources:
+    cpu: 200m
+    memory: 512Mi
diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml
index 2f305dc..c7a7de2 100644
--- a/config/samples/kustomization.yaml
+++ b/config/samples/kustomization.yaml
@@ -3,4 +3,5 @@
 resources:
   - k6_v1alpha1_configmap.yaml
   - k6_v1alpha1_k6.yaml
+  - k6_v1alpha1_privateloadzone.yaml
 # +kubebuilder:scaffold:manifestskustomizesamples
diff --git a/controllers/common.go b/controllers/common.go
new file mode 100644
index 0000000..c0c775b
--- /dev/null
+++ b/controllers/common.go
@@ -0,0 +1,179 @@
+package controllers
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"time"
+
+	"github.com/go-logr/logr"
+	"github.com/grafana/k6-operator/api/v1alpha1"
+	"github.com/grafana/k6-operator/pkg/cloud"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/rest"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// It may take some time to retrieve inspect output so indicate with boolean if it's ready
+// and use returnErr only for errors that require a change of behaviour. All other errors
+// should just be logged.
+func inspectTestRun(ctx context.Context, log logr.Logger, k6 v1alpha1.K6, c client.Client) (
+	inspectOutput cloud.InspectOutput, ready bool, returnErr error) {
+	var (
+		listOpts = &client.ListOptions{
+			Namespace: k6.Namespace,
+			LabelSelector: labels.SelectorFromSet(map[string]string{
+				"app":      "k6",
+				"k6_cr":    k6.Name,
+				"job-name": fmt.Sprintf("%s-initializer", k6.Name),
+			}),
+		}
+		podList = &corev1.PodList{}
+		err     error
+	)
+	if err = c.List(ctx, podList, listOpts); err != nil {
+		log.Error(err, "Could not list pods")
+		return
+	}
+	if len(podList.Items) < 1 {
+		log.Info("No initializing pod found yet")
+		return
+	}
+
+	// there should be only 1 initializer pod
+	if podList.Items[0].Status.Phase != "Succeeded" {
+		log.Info("Waiting for initializing pod to finish")
+		return
+	}
+
+	// Here we need to get the output of the pod
+	// pods/log is not currently supported by controller-runtime client and it is officially
+	// recommended to use REST client instead:
+	// https://github.com/kubernetes-sigs/controller-runtime/issues/1229
+
+	// TODO: if the below errors repeat several times, it'd be a real error case scenario.
+	// How likely is it? Should we track frequency of these errors here?
+	config, err := rest.InClusterConfig()
+	if err != nil {
+		log.Error(err, "unable to fetch in-cluster REST config")
+		return
+	}
+
+	clientset, err := kubernetes.NewForConfig(config)
+	if err != nil {
+		log.Error(err, "unable to get access to clientset")
+		return
+	}
+	req := clientset.CoreV1().Pods(k6.Namespace).GetLogs(podList.Items[0].Name, &corev1.PodLogOptions{
+		Container: "k6",
+	})
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*60)
+	defer cancel()
+
+	podLogs, err := req.Stream(ctx)
+	if err != nil {
+		log.Error(err, "unable to stream logs from the pod")
+		return
+	}
+	defer podLogs.Close()
+
+	buf := new(bytes.Buffer)
+	_, err = io.Copy(buf, podLogs)
+	if err != nil {
+		log.Error(err, "unable to copy logs from the pod")
+		return
+	}
+
+	if returnErr = json.Unmarshal(buf.Bytes(), &inspectOutput); returnErr != nil {
+		// this shouldn't normally happen but if it does, let's log output by default
+		log.Error(returnErr, fmt.Sprintf("unable to marshal: `%s`", buf.String()))
+	}
+
+	ready = true
+	return
+}
+
+// Similarly to inspectTestRun, there may be some errors during load of token
+// that should be just waited out. But other errors should result in change of
+// behaviour in the caller.
+// ready shows whether token was loaded yet, while returnErr indicates an error
+// that should be acted on.
+//
+// Currently we support two modes for loading token:
+// - by name of the token (PLZ mode)
+// - by label selector (cloud output mode)
+// Specify arguments to loadToken accordingly.
+func loadToken(ctx context.Context, log logr.Logger, c client.Client, secretName string, sOpts *client.ListOptions) (token string, ready bool, returnErr error) {
+	var (
+		secrets corev1.SecretList
+		secret  corev1.Secret
+		// This is the default location of the token;
+		// what is used by cloud output mode.
+		secretOpts = &client.ListOptions{
+			Namespace: "k6-operator-system",
+			LabelSelector: labels.SelectorFromSet(map[string]string{
+				"k6cloud": "token",
+			}),
+		}
+		err error
+	)
+
+	if sOpts != nil && len(secretName) > 0 {
+		// PLZ mode case
+		secretOpts = sOpts
+	}
+
+	if len(secretName) > 0 {
+		log.Info("Loading token by name.", "name", secretName, "secretNamespace", secretOpts.Namespace)
+
+		if err := c.Get(ctx, types.NamespacedName{Namespace: secretOpts.Namespace, Name: secretName}, &secret); err != nil {
+			log.Error(err, "Failed to load k6 Cloud token", "name", secretName, "secretNamespace", secretOpts.Namespace)
+			// This may be a networking issue, etc. so just retry.
+			return
+		}
+	} else {
+		log.Info("Loading token by label pair.", "labelset", secretOpts.LabelSelector.String(), "secretNamespace", secretOpts.Namespace)
+
+		if err = c.List(ctx, &secrets, secretOpts); err != nil {
+			log.Error(err, "Failed to load k6 Cloud token", "labelset", secretOpts.LabelSelector.String(), "secretNamespace", secretOpts.Namespace)
+			// This may be a networking issue, etc. so just retry.
+			return
+		}
+
+		if len(secrets.Items) < 1 {
+			// we should stop execution in case of this error
+			returnErr = fmt.Errorf("There are no secrets to hold k6 Cloud token")
+			log.Error(returnErr, returnErr.Error(), "labelset", secretOpts.LabelSelector.String(), "secretNamespace", secretOpts.Namespace)
+			return
+		}
+
+		secret = secrets.Items[0]
+	}
+
+	if t, ok := secret.Data["token"]; !ok {
+		// we should stop execution in case of this error
+		returnErr = fmt.Errorf("The secret doesn't have a field token for k6 Cloud")
+		log.Error(err, err.Error())
+		return
+	} else {
+		token = string(t)
+		ready = true
+		log.Info("Token for k6 Cloud was loaded.")
+	}
+
+	return
+}
+
+func getEnvVar(vars []corev1.EnvVar, name string) string {
+	for _, v := range vars {
+		if v.Name == name {
+			return v.Value
+		}
+	}
+	return ""
+}
diff --git a/controllers/k6_controller.go b/controllers/k6_controller.go
index 7c23e20..689904d 100644
--- a/controllers/k6_controller.go
+++ b/controllers/k6_controller.go
@@ -82,7 +82,7 @@ func (r *K6Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Re
 	case "":
 		log.Info("Initialize test")
 
-		k6.InitializeConditions()
+		k6.Initialize()
 
 		if _, err := r.UpdateStatus(ctx, k6, log); err != nil {
 			return ctrl.Result{}, err
@@ -122,7 +122,7 @@ func (r *K6Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Re
 
 		if k6.IsTrue(v1alpha1.CloudTestRun) {
 
-			if k6.IsFalse(v1alpha1.CloudTestRunCreated) {
+			if k6.IsFalse(v1alpha1.CloudTestRunCreated) && k6.IsFalse(v1alpha1.CloudPLZTestRun) {
 				return SetupCloudTest(ctx, log, k6, r)
 
 			} else {
@@ -173,7 +173,9 @@ func (r *K6Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Re
 			k6.Status.Stage = "finished"
 
 			// If this is a test run with cloud output, try to finalize it.
-			if k6.IsTrue(v1alpha1.CloudTestRun) && k6.IsFalse(v1alpha1.CloudTestRunFinalized) {
+			if k6.IsTrue(v1alpha1.CloudTestRun) &&
+				k6.IsFalse(v1alpha1.CloudPLZTestRun) &&
+				k6.IsFalse(v1alpha1.CloudTestRunFinalized) {
 				if err = cloud.FinishTestRun(k6.Status.TestRunID); err != nil {
 					log.Error(err, "Failed to finalize the test run with cloud output")
 					return ctrl.Result{}, nil
diff --git a/controllers/k6_create.go b/controllers/k6_create.go
index b36b258..68fbf72 100644
--- a/controllers/k6_create.go
+++ b/controllers/k6_create.go
@@ -13,6 +13,7 @@ import (
 	"k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/types"
 	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
 // CreateJobs creates jobs that will spawn k6 pods for distributed test
@@ -27,7 +28,7 @@ func CreateJobs(ctx context.Context, log logr.Logger, k6 *v1alpha1.K6, r *K6Reco
 		log = log.WithValues("testRunId", k6.Status.TestRunID)
 
 		var tokenReady bool
-		token, tokenReady, err = loadToken(ctx, log, r)
+		token, tokenReady, err = loadToken(ctx, log, r.Client, k6.Spec.Token, &client.ListOptions{Namespace: k6.Namespace})
 		if err != nil {
 			// An error here means a very likely mis-configuration of the token.
 			// Consider updating status to error to let a user know quicker?
diff --git a/controllers/k6_initialize.go b/controllers/k6_initialize.go
index 531a9e5..a1c6a2c 100644
--- a/controllers/k6_initialize.go
+++ b/controllers/k6_initialize.go
@@ -1,11 +1,8 @@
 package controllers
 
 import (
-	"bytes"
 	"context"
-	"encoding/json"
 	"fmt"
-	"io"
 	"time"
 
 	"github.com/grafana/k6-operator/pkg/types"
@@ -15,13 +12,8 @@ import (
 	"github.com/grafana/k6-operator/pkg/cloud"
 	"github.com/grafana/k6-operator/pkg/resources/jobs"
 	batchv1 "k8s.io/api/batch/v1"
-	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/labels"
-	"k8s.io/client-go/kubernetes"
-	"k8s.io/client-go/rest"
 	ctrl "sigs.k8s.io/controller-runtime"
-	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
 // InitializeJobs creates jobs that will run initial checks for distributed test if any are necessary
@@ -29,7 +21,7 @@ func InitializeJobs(ctx context.Context, log logr.Logger, k6 *v1alpha1.K6, r *K6
 	// initializer is a quick job so check in frequently
 	res = ctrl.Result{RequeueAfter: time.Second * 5}
 
-	cli := types.ParseCLI(&k6.Spec)
+	cli := types.ParseCLI(k6.Spec.Arguments)
 
 	var initializer *batchv1.Job
 	if initializer, err = jobs.NewInitializerJob(k6, cli.ArchiveArgs); err != nil {
@@ -56,9 +48,9 @@ func RunValidations(ctx context.Context, log logr.Logger, k6 *v1alpha1.K6, r *K6
 	// initializer is a quick job so check in frequently
 	res = ctrl.Result{RequeueAfter: time.Second * 5}
 
-	cli := types.ParseCLI(&k6.Spec)
+	cli := types.ParseCLI(k6.Spec.Arguments)
 
-	inspectOutput, inspectReady, err := inspectTestRun(ctx, log, *k6, r)
+	inspectOutput, inspectReady, err := inspectTestRun(ctx, log, *k6, r.Client)
 	if err != nil {
 		// inspectTestRun made a log message already so just return without requeue
 		return ctrl.Result{}, nil
@@ -103,10 +95,12 @@ func RunValidations(ctx context.Context, log logr.Logger, k6 *v1alpha1.K6, r *K6
 	return res, nil
 }
 
+// SetupCloudTest inspects the output of initializer and creates a new
+// test run. It is meant to be used only in cloud output mode.
 func SetupCloudTest(ctx context.Context, log logr.Logger, k6 *v1alpha1.K6, r *K6Reconciler) (res ctrl.Result, err error) {
 	res = ctrl.Result{RequeueAfter: time.Second * 5}
 
-	inspectOutput, inspectReady, err := inspectTestRun(ctx, log, *k6, r)
+	inspectOutput, inspectReady, err := inspectTestRun(ctx, log, *k6, r.Client)
 	if err != nil {
 		// This *shouldn't* fail since it was already done once. Don't requeue.
 		// Alternatively: store inspect options in K6 Status? Get rid off reading logs?
@@ -116,7 +110,7 @@ func SetupCloudTest(ctx context.Context, log logr.Logger, k6 *v1alpha1.K6, r *K6
 		return res, nil
 	}
 
-	token, tokenReady, err := loadToken(ctx, log, r)
+	token, tokenReady, err := loadToken(ctx, log, r.Client, "", nil)
 	if err != nil {
 		// An error here means a very likely mis-configuration of the token.
 		// Consider updating status to error to let a user know quicker?
@@ -160,136 +154,3 @@ func SetupCloudTest(ctx context.Context, log logr.Logger, k6 *v1alpha1.K6, r *K6
 
 	return ctrl.Result{}, nil
 }
-
-// It may take some time to retrieve inspect output so indicate with boolean if it's ready
-// and use returnErr only for errors that require a change of behaviour. All other errors
-// should just be logged.
-func inspectTestRun(ctx context.Context, log logr.Logger, k6 v1alpha1.K6, r *K6Reconciler) (
-	inspectOutput cloud.InspectOutput, ready bool, returnErr error) {
-	var (
-		listOpts = &client.ListOptions{
-			Namespace: k6.Namespace,
-			LabelSelector: labels.SelectorFromSet(map[string]string{
-				"app":      "k6",
-				"k6_cr":    k6.Name,
-				"job-name": fmt.Sprintf("%s-initializer", k6.Name),
-			}),
-		}
-		podList = &corev1.PodList{}
-		err     error
-	)
-	if err = r.List(ctx, podList, listOpts); err != nil {
-		log.Error(err, "Could not list pods")
-		return
-	}
-	if len(podList.Items) < 1 {
-		log.Info("No initializing pod found yet")
-		return
-	}
-
-	// there should be only 1 initializer pod
-	if podList.Items[0].Status.Phase != "Succeeded" {
-		log.Info("Waiting for initializing pod to finish")
-		return
-	}
-
-	// Here we need to get the output of the pod
-	// pods/log is not currently supported by controller-runtime client and it is officially
-	// recommended to use REST client instead:
-	// https://github.com/kubernetes-sigs/controller-runtime/issues/1229
-
-	// TODO: if the below errors repeat several times, it'd be a real error case scenario.
-	// How likely is it? Should we track frequency of these errors here?
-	config, err := rest.InClusterConfig()
-	if err != nil {
-		log.Error(err, "unable to fetch in-cluster REST config")
-		return
-	}
-
-	clientset, err := kubernetes.NewForConfig(config)
-	if err != nil {
-		log.Error(err, "unable to get access to clientset")
-		return
-	}
-	req := clientset.CoreV1().Pods(k6.Namespace).GetLogs(podList.Items[0].Name, &corev1.PodLogOptions{
-		Container: "k6",
-	})
-	ctx, cancel := context.WithTimeout(context.Background(), time.Second*60)
-	defer cancel()
-
-	podLogs, err := req.Stream(ctx)
-	if err != nil {
-		log.Error(err, "unable to stream logs from the pod")
-		return
-	}
-	defer podLogs.Close()
-
-	buf := new(bytes.Buffer)
-	_, err = io.Copy(buf, podLogs)
-	if err != nil {
-		log.Error(err, "unable to copy logs from the pod")
-		return
-	}
-
-	if returnErr = json.Unmarshal(buf.Bytes(), &inspectOutput); returnErr != nil {
-		// this shouldn't normally happen but if it does, let's log output by default
-		log.Error(returnErr, fmt.Sprintf("unable to marshal: `%s`", buf.String()))
-	}
-
-	ready = true
-	return
-}
-
-// Similarly to inspectTestRun, there may be some errors during load of token
-// that should be just waited out. But other errors should result in change of
-// behaviour in the caller.
-// ready shows whether token was loaded yet, while returnErr indicates an error
-// that should be acted on.
-func loadToken(ctx context.Context, log logr.Logger, r *K6Reconciler) (token string, ready bool, returnErr error) {
-	var (
-		secrets    corev1.SecretList
-		secretOpts = &client.ListOptions{
-			// TODO: find out a better way to get namespace here
-			Namespace: "k6-operator-system",
-			LabelSelector: labels.SelectorFromSet(map[string]string{
-				"k6cloud": "token",
-			}),
-		}
-		err error
-	)
-
-	if err = r.List(ctx, &secrets, secretOpts); err != nil {
-		log.Error(err, "Failed to load k6 Cloud token")
-		// This may be a networking issue, etc. so just retry.
-		return
-	}
-
-	if len(secrets.Items) < 1 {
-		// we should stop execution in case of this error
-		returnErr = fmt.Errorf("There are no secrets to hold k6 Cloud token")
-		log.Error(returnErr, err.Error())
-		return
-	}
-
-	if t, ok := secrets.Items[0].Data["token"]; !ok {
-		// we should stop execution in case of this error
-		returnErr = fmt.Errorf("The secret doesn't have a field token for k6 Cloud")
-		log.Error(err, err.Error())
-		return
-	} else {
-		token = string(t)
-		ready = true
-		log.Info("Token for k6 Cloud was loaded.")
-	}
-
-	return
-}
-
-func getEnvVar(vars []corev1.EnvVar, name string) string {
-	for _, v := range vars {
-		if v.Name == name {
-			return v.Value
-		}
-	}
-	return ""
-}
diff --git a/controllers/plz_controller.go b/controllers/plz_controller.go
new file mode 100644
index 0000000..1f57115
--- /dev/null
+++ b/controllers/plz_controller.go
@@ -0,0 +1,182 @@
+/*
+
+
+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 controllers
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/go-logr/logr"
+	k8sErrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/types"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+	"github.com/grafana/k6-operator/api/v1alpha1"
+	k6v1alpha1 "github.com/grafana/k6-operator/api/v1alpha1"
+	"github.com/grafana/k6-operator/pkg/cloud"
+)
+
+const plzFinalizer = "privateloadzones.k6.io/finalizer"
+
+// PrivateLoadZoneReconciler reconciles a PrivateLoadZone object
+type PrivateLoadZoneReconciler struct {
+	client.Client
+	Log    logr.Logger
+	Scheme *runtime.Scheme
+
+	// Note: we expect that there's only one PLZ object at a time.
+	// Therefore it is safe to assume that poller should be created only once
+	// and it can simply be part of the Reconciler object.
+	// If support for multiple PLZs will be added at some point,
+	// poller should be made PLZ specific;
+	// e.g. with a map: PLZ name -> poller.
+	poller *cloud.TestRunPoller
+}
+
+//+kubebuilder:rbac:groups=k6.io,resources=privateloadzones,verbs=get;list;watch;create;update;patch;delete
+//+kubebuilder:rbac:groups=k6.io,resources=privateloadzones/status,verbs=get;update;patch
+//+kubebuilder:rbac:groups=k6.io,resources=privateloadzones/finalizers,verbs=get;update;patch
+
+// Reconcile takes a PrivateLoadZone object and takes the appropriate action in the cluster
+func (r *PrivateLoadZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	logger := r.Log.WithValues("namespace", req.Namespace, "name", req.Name, "reconcileID", controller.ReconcileIDFromContext(ctx))
+
+	plz := &v1alpha1.PrivateLoadZone{}
+	err := r.Get(ctx, req.NamespacedName, plz)
+	if err != nil {
+		if k8sErrors.IsNotFound(err) {
+			logger.Info("Request deleted. Nothing to reconcile.")
+			return ctrl.Result{}, nil
+		}
+		logger.Error(err, "Could not fetch request")
+		return ctrl.Result{Requeue: true}, err
+	}
+
+	if r.poller == nil {
+		token, tokenReady, err := loadToken(ctx, logger, r.Client, plz.Spec.Token, &client.ListOptions{Namespace: plz.Namespace})
+		if err != nil {
+			// An error here means a very likely mis-configuration of the token.
+			logger.Error(err, "A problem while getting token.")
+			return ctrl.Result{}, nil
+		}
+		if !tokenReady {
+			return ctrl.Result{RequeueAfter: time.Second * 5}, nil
+		}
+
+		r.poller = cloud.NewTestRunPoller("http://mock-cloud.k6-operator-system.svc.cluster.local:8080", token, logger)
+	}
+
+	// fmt.Println("finalizers check", plz.DeletionTimestamp, plz.GetFinalizers())
+	if plz.DeletionTimestamp.IsZero() && (plz.IsUnknown(v1alpha1.PLZRegistered) || plz.IsFalse(v1alpha1.PLZRegistered)) {
+		plz.Initialize()
+
+		plz.Register(ctx, logger, r.poller.Client)
+		logger.Info(fmt.Sprintf("PLZ %s is registered.", plz.Name))
+
+		controllerutil.AddFinalizer(plz, plzFinalizer)
+		// fmt.Println("register call and adding finalizers", plz.GetFinalizers())
+
+		if updateHappened, err := r.UpdateStatus(ctx, plz, logger); err != nil {
+			return ctrl.Result{}, err
+		} else if updateHappened {
+			return ctrl.Result{}, nil
+		}
+	} else {
+		if !plz.DeletionTimestamp.IsZero() && controllerutil.ContainsFinalizer(plz, plzFinalizer) {
+			r.poller.Stop()
+
+			plz.Deregister(ctx, logger, r.poller.Client)
+			logger.Info(fmt.Sprintf("PLZ %s is deregistered.", plz.Name))
+
+			controllerutil.RemoveFinalizer(plz, plzFinalizer)
+
+			if _, err := r.UpdateStatus(ctx, plz, logger); err != nil {
+				return ctrl.Result{}, err
+			} else { //if updateHappened {
+				return ctrl.Result{}, nil
+			}
+		}
+	}
+
+	if plz.IsTrue(v1alpha1.PLZRegistered) {
+		if r.poller != nil && !r.poller.IsPolling() {
+			r.poller.Start()
+			r.startFactory(plz, r.poller.GetTestRuns())
+			logger.Info("Started polling k6 Cloud for new test runs.")
+		}
+	}
+
+	return ctrl.Result{}, nil
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *PrivateLoadZoneReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&k6v1alpha1.PrivateLoadZone{}).
+		Complete(r)
+}
+
+// UpdateStatus is now using similar logic to K6Reconciler:
+// see if it can / should be refactored.
+func (r *PrivateLoadZoneReconciler) UpdateStatus(
+	ctx context.Context,
+	plz *v1alpha1.PrivateLoadZone,
+	log logr.Logger) (updateHappened bool, err error) {
+
+	proposedStatus := plz.Status
+
+	// re-fetch resource
+	err = r.Get(ctx, types.NamespacedName{Namespace: plz.Namespace, Name: plz.Name}, plz)
+	if err != nil {
+		if k8sErrors.IsNotFound(err) {
+			log.Info("Request deleted. No status to update.")
+			return false, nil
+		}
+		log.Error(err, "Could not fetch request")
+		return false, err
+	}
+
+	cleanObj := plz.DeepCopyObject().(client.Object)
+
+	// Update only if it's truly a newer version of the resource
+	// in comparison to the recently fetched resource.
+	isNewer := plz.Status.SetIfNewer(proposedStatus)
+	if !isNewer {
+		return false, nil
+	}
+
+	err = r.Client.Status().Patch(ctx, plz, client.MergeFrom(cleanObj))
+
+	// TODO: look into retry.RetryOnConflict(retry.DefaultRetry, func() error{...})
+	// to have retries of failing update here, in case of conflicts;
+	// with optional retry bool arg probably.
+
+	// TODO: what if resource was deleted right before Patch?
+	// Add a check for IsNotFound(err).
+
+	if err != nil {
+		log.Error(err, "Could not update status of custom resource")
+		return false, err
+	}
+
+	return true, nil
+}
diff --git a/controllers/plz_factory.go b/controllers/plz_factory.go
new file mode 100644
index 0000000..6d88b1c
--- /dev/null
+++ b/controllers/plz_factory.go
@@ -0,0 +1,61 @@
+package controllers
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/grafana/k6-operator/api/v1alpha1"
+	"github.com/grafana/k6-operator/pkg/cloud"
+	"github.com/grafana/k6-operator/pkg/testrun"
+	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+
+	ctrl "sigs.k8s.io/controller-runtime"
+)
+
+func (r *PrivateLoadZoneReconciler) startFactory(plz *v1alpha1.PrivateLoadZone, testRunCh chan string) {
+	go func() {
+		logger := r.Log.WithValues("namespace", plz.Namespace, "name", plz.Name)
+
+		logger.Info("Started factory for PLZ test runs.")
+
+		for testRunId := range testRunCh {
+			// First check if such a test already exists
+			namespacedName := types.NamespacedName{
+				Name:      testrun.TestName(testRunId),
+				Namespace: plz.Namespace,
+			}
+
+			k6 := &v1alpha1.K6{}
+			if err := r.Get(context.Background(), namespacedName, k6); err == nil || !errors.IsNotFound(err) {
+				logger.Info(fmt.Sprintf("Test run `%s` has already been started.", testRunId))
+				// fmt.Println(k6)
+				continue
+			}
+
+			// Test does not exist so get its data and create it.
+
+			// TODO get test run data
+
+			trData := cloud.TestRunData{
+				TestRunId: testRunId,
+				Instances: 1,
+			}
+
+			k6 = testrun.NewPLZTestRun(plz, trData)
+
+			logger.Info(fmt.Sprintf("PLZ test run has been prepared with image `%s` and `%d` instances",
+				k6.Spec.Runner.Image, k6.Spec.Parallelism), "testRunId", testRunId)
+
+			if err := ctrl.SetControllerReference(plz, k6, r.Scheme); err != nil {
+				logger.Error(err, "Failed to set controller reference for the PLZ test run", "testRunId", testRunId)
+			}
+
+			if err := r.Create(context.Background(), k6); err != nil {
+				logger.Error(err, "Failed to create PLZ test run", "testRunId", testRunId)
+			}
+
+			logger.Info("Created new test run", "testRunId", testRunId)
+		}
+	}()
+}
diff --git a/main.go b/main.go
index 08ca964..0dd0dfb 100644
--- a/main.go
+++ b/main.go
@@ -80,6 +80,14 @@ func main() {
 		setupLog.Error(err, "unable to create controller", "controller", "K6")
 		os.Exit(1)
 	}
+	if err = (&controllers.PrivateLoadZoneReconciler{
+		Client: mgr.GetClient(),
+		Log:    ctrl.Log.WithName("controllers").WithName("PrivateLoadZone"),
+		Scheme: mgr.GetScheme(),
+	}).SetupWithManager(mgr); err != nil {
+		setupLog.Error(err, "unable to create controller", "controller", "PrivateLoadZone")
+		os.Exit(1)
+	}
 	// +kubebuilder:scaffold:builder
 
 	setupLog.Info("starting manager")
diff --git a/pkg/cloud/cloud.go b/pkg/cloud/cloud_output.go
similarity index 86%
rename from pkg/cloud/cloud.go
rename to pkg/cloud/cloud_output.go
index 058eddc..b9cbe0b 100644
--- a/pkg/cloud/cloud.go
+++ b/pkg/cloud/cloud_output.go
@@ -9,25 +9,11 @@ import (
 	"github.com/sirupsen/logrus"
 	"go.k6.io/k6/cloudapi"
 	"go.k6.io/k6/lib/consts"
-	"go.k6.io/k6/lib/types"
-	"go.k6.io/k6/metrics"
 	"gopkg.in/guregu/null.v3"
 )
 
 var client *cloudapi.Client
 
-type InspectOutput struct {
-	External struct {
-		Loadimpact struct {
-			Name      string `json:"name"`
-			ProjectID int64  `json:"projectID"`
-		} `json:"loadimpact"`
-	} `json:"ext"`
-	TotalDuration types.NullDuration             `json:"totalDuration"`
-	MaxVUs        uint64                         `json:"maxVUs"`
-	Thresholds    map[string]*metrics.Thresholds `json:"thresholds,omitempty"`
-}
-
 type TestRun struct {
 	Name              string              `json:"name"`
 	ProjectID         int64               `json:"project_id,omitempty"`
diff --git a/pkg/cloud/conn/poller.go b/pkg/cloud/conn/poller.go
new file mode 100644
index 0000000..a89bd15
--- /dev/null
+++ b/pkg/cloud/conn/poller.go
@@ -0,0 +1,58 @@
+package conn
+
+import (
+	"time"
+)
+
+type Poller struct {
+	interval time.Duration
+	running  bool
+	done     chan bool
+
+	OnInterval func()
+	OnDone     func()
+}
+
+func NewPoller(interval time.Duration) *Poller {
+	return &Poller{
+		interval: interval,
+		running:  false,
+		done:     make(chan bool),
+
+		// by default, poller does nothing
+		OnInterval: func() {},
+		OnDone:     func() {},
+	}
+}
+
+func (poller Poller) IsPolling() bool {
+	return poller.running
+}
+
+func (poller *Poller) Start() {
+	ticker := time.NewTicker(poller.interval)
+
+	go func() {
+		for {
+			select {
+			case <-poller.done:
+				ticker.Stop()
+				poller.OnDone()
+				return
+
+			case <-ticker.C:
+				poller.OnInterval()
+			}
+		}
+	}()
+
+	poller.running = true
+}
+
+func (poller *Poller) Stop() {
+	if poller.IsPolling() {
+		poller.done <- true
+
+		poller.running = false
+	}
+}
diff --git a/pkg/cloud/types.go b/pkg/cloud/types.go
new file mode 100644
index 0000000..f93bef0
--- /dev/null
+++ b/pkg/cloud/types.go
@@ -0,0 +1,31 @@
+package cloud
+
+import (
+	"go.k6.io/k6/lib/types"
+	"go.k6.io/k6/metrics"
+)
+
+// InspectOutput is the parsed output from `k6 inspect --execution-requirements`.
+type InspectOutput struct {
+	External struct {
+		Loadimpact struct {
+			Name      string `json:"name"`
+			ProjectID int64  `json:"projectID"`
+		} `json:"loadimpact"`
+	} `json:"ext"`
+	TotalDuration types.NullDuration             `json:"totalDuration"`
+	MaxVUs        uint64                         `json:"maxVUs"`
+	Thresholds    map[string]*metrics.Thresholds `json:"thresholds,omitempty"`
+}
+
+// testRunList holds the output from /get-tests call
+type testRunList struct {
+	List []string `json:"list"`
+}
+
+// TestRunData holds the output from /get-test-data call
+type TestRunData struct {
+	TestRunId string `json:"testRunId"`
+	// ArchiveURL
+	Instances int `json:"instances"`
+}
diff --git a/pkg/resources/jobs/initializer.go b/pkg/resources/jobs/initializer.go
index 1d067e8..581256e 100644
--- a/pkg/resources/jobs/initializer.go
+++ b/pkg/resources/jobs/initializer.go
@@ -5,7 +5,6 @@ import (
 	"strconv"
 
 	"github.com/grafana/k6-operator/api/v1alpha1"
-	"github.com/grafana/k6-operator/pkg/types"
 	batchv1 "k8s.io/api/batch/v1"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -13,7 +12,7 @@ import (
 
 // NewInitializerJob builds a template used to initializefor creating a starter job
 func NewInitializerJob(k6 *v1alpha1.K6, argLine string) (*batchv1.Job, error) {
-	script, err := types.ParseScript(&k6.Spec)
+	script, err := k6.Spec.Script.Parse()
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/resources/jobs/runner.go b/pkg/resources/jobs/runner.go
index 1950645..ea2df49 100644
--- a/pkg/resources/jobs/runner.go
+++ b/pkg/resources/jobs/runner.go
@@ -11,7 +11,6 @@ import (
 	"github.com/grafana/k6-operator/api/v1alpha1"
 	"github.com/grafana/k6-operator/pkg/cloud"
 	"github.com/grafana/k6-operator/pkg/segmentation"
-	"github.com/grafana/k6-operator/pkg/types"
 	batchv1 "k8s.io/api/batch/v1"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -44,7 +43,7 @@ func NewRunnerJob(k6 *v1alpha1.K6, index int, token string) (*batchv1.Job, error
 		command = append(command, args...)
 	}
 
-	script, err := types.ParseScript(&k6.Spec)
+	script, err := k6.Spec.Script.Parse()
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/types/conditions.go b/pkg/types/conditions.go
new file mode 100644
index 0000000..be685af
--- /dev/null
+++ b/pkg/types/conditions.go
@@ -0,0 +1,92 @@
+package types
+
+import (
+	"fmt"
+
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func UpdateCondition(conditions *[]metav1.Condition, conditionType string, conditionStatus metav1.ConditionStatus) {
+	reason, ok := reasons[conditionType+string(conditionStatus)]
+	if !ok {
+		panic(fmt.Sprintf("Invalid condition type and status! `%s` - this should never happen!", conditionType+string(conditionStatus)))
+	}
+	meta.SetStatusCondition(conditions, metav1.Condition{
+		Type:               conditionType,
+		Status:             conditionStatus,
+		LastTransitionTime: metav1.Now(),
+		Reason:             reason,
+		Message:            "",
+	})
+}
+
+// SetIfNewer changes cond only if changes in proposedCond are consistent
+// with the expected change of conditions both logically and chronologically.
+// callbackF can be provided to run a custom function during the loop
+// over proposedCond.
+// If there were any acceptable changes proposed, it returns true.
+func SetIfNewer(cond *[]metav1.Condition,
+	proposedCond []metav1.Condition,
+	callbackF func(metav1.Condition) bool) (isNewer bool) {
+
+	existingConditions := map[string]metav1.Condition{}
+	for i := range *cond {
+		existingConditions[(*cond)[i].Type] = (*cond)[i]
+	}
+
+	for _, proposedCondition := range proposedCond {
+		// If a new condition is being proposed, just add it to the list.
+		if existingCondition, ok := existingConditions[proposedCondition.Type]; !ok {
+			*cond = append(*cond, proposedCondition)
+			isNewer = true
+		} else {
+			// If a change in existing condition is being proposed, check if
+			// its timestamp is later than the one in existing condition.
+			//
+			// Additionally: condition should never return to Unknown status
+			// unless it's newly created.
+
+			if proposedCondition.Status != metav1.ConditionUnknown {
+				if existingCondition.LastTransitionTime.UnixNano() < proposedCondition.LastTransitionTime.UnixNano() {
+					meta.SetStatusCondition(cond, proposedCondition)
+					isNewer = true
+				}
+			}
+		}
+
+		if callbackF != nil {
+			if callbackResult := callbackF(proposedCondition); callbackResult == true {
+				isNewer = callbackResult
+			}
+		}
+	}
+
+	return
+}
+
+var reasons = map[string]string{
+	"TestRunRunningUnknown": "TestRunPreparation",
+	"TestRunRunningTrue":    "TestRunRunningTrue",
+	"TestRunRunningFalse":   "TestRunRunningFalse",
+
+	"CloudTestRunUnknown": "TestRunTypeUnknown",
+	"CloudTestRunTrue":    "CloudTestRunTrue",
+	"CloudTestRunFalse":   "CloudTestRunFalse",
+
+	"CloudTestRunCreatedUnknown": "CloudTestRunCreatedUnknown",
+	"CloudTestRunCreatedTrue":    "CloudTestRunCreatedTrue",
+	"CloudTestRunCreatedFalse":   "CloudTestRunCreatedFalse",
+
+	"CloudTestRunFinalizedUnknown": "CloudTestRunFinalizedUnknown",
+	"CloudTestRunFinalizedTrue":    "CloudTestRunFinalizedTrue",
+	"CloudTestRunFinalizedFalse":   "CloudTestRunFinalizedFalse",
+
+	"CloudPLZTestRunUnknown": "CloudPLZTestRunUnknown",
+	"CloudPLZTestRunTrue":    "CloudPLZTestRunTrue",
+	"CloudPLZTestRunFalse":   "CloudPLZTestRunFalse",
+
+	"PLZRegisteredUnknown": "PLZRegisteredUnknown",
+	"PLZRegisteredTrue":    "PLZRegisteredTrue",
+	"PLZRegisteredFalse":   "PLZRegisteredFalse",
+}
diff --git a/pkg/types/types.go b/pkg/types/types.go
index c299793..04c3353 100644
--- a/pkg/types/types.go
+++ b/pkg/types/types.go
@@ -1,12 +1,9 @@
 package types
 
 import (
-	"errors"
 	"fmt"
-	"path/filepath"
 	"strings"
 
-	"github.com/grafana/k6-operator/api/v1alpha1"
 	corev1 "k8s.io/api/core/v1"
 )
 
@@ -18,44 +15,6 @@ type Script struct {
 	Type     string // ConfigMap | VolumeClaim | LocalFile
 }
 
-// ParseScript extracts Script data bits from K6 spec and performs basic validation
-func ParseScript(spec *v1alpha1.K6Spec) (*Script, error) {
-	s := &Script{
-		Filename: "test.js",
-		Path:     "/test/",
-	}
-
-	if spec.Script.VolumeClaim.Name != "" {
-		s.Name = spec.Script.VolumeClaim.Name
-		if spec.Script.VolumeClaim.File != "" {
-			s.Filename = spec.Script.VolumeClaim.File
-		}
-
-		s.Type = "VolumeClaim"
-		return s, nil
-	}
-
-	if spec.Script.ConfigMap.Name != "" {
-		s.Name = spec.Script.ConfigMap.Name
-
-		if spec.Script.ConfigMap.File != "" {
-			s.Filename = spec.Script.ConfigMap.File
-		}
-
-		s.Type = "ConfigMap"
-		return s, nil
-	}
-
-	if spec.Script.LocalFile != "" {
-		s.Name = "LocalFile"
-		s.Type = "LocalFile"
-		s.Path, s.Filename = filepath.Split(spec.Script.LocalFile)
-		return s, nil
-	}
-
-	return nil, errors.New("Script definition should contain one of: ConfigMap, VolumeClaim, LocalFile")
-}
-
 func (s *Script) FullName() string {
 	return s.Path + s.Filename
 }
@@ -127,7 +86,7 @@ type CLI struct {
 	HasCloudOut bool
 }
 
-func ParseCLI(spec *v1alpha1.K6Spec) *CLI {
+func ParseCLI(arguments string) *CLI {
 	lastArgV := func(start int, args []string) (end int) {
 		var nextArg bool
 		end = start
@@ -144,7 +103,7 @@ func ParseCLI(spec *v1alpha1.K6Spec) *CLI {
 
 	var cli CLI
 
-	args := strings.Split(spec.Arguments, " ")
+	args := strings.Split(arguments, " ")
 	i := 0
 	for i < len(args) {
 		args[i] = strings.TrimSpace(args[i])


Test patch:
diff --git a/pkg/cloud/conn/poller_test.go b/pkg/cloud/conn/poller_test.go
new file mode 100644
index 0000000..a00f4f5
--- /dev/null
+++ b/pkg/cloud/conn/poller_test.go
@@ -0,0 +1,44 @@
+package conn
+
+import (
+	"testing"
+	"time"
+)
+
+func Test_PollerStops(t *testing.T) {
+	var x int
+
+	poller := NewPoller(time.Millisecond * 100)
+	poller.OnInterval = func() {
+		// sleep for 200ms before changing the var so that each polling loop
+		// takes twice longer than the ticker itself
+		time.Sleep(time.Millisecond * 200)
+		x++
+	}
+
+	pollerStopped := make(chan struct{})
+
+	poller.Start()
+
+	go func() {
+		// If we stop after 300ms, then there should be ~ 2
+		// polling loops done in total.
+		time.Sleep(time.Millisecond * 300)
+		poller.Stop()
+		pollerStopped <- struct{}{}
+	}()
+
+	select {
+	case <-pollerStopped:
+		if poller.IsPolling() {
+			t.Error("Poller is running even though pollerStopped was marked: something may be off with the test.")
+		}
+
+		if x == 0 {
+			t.Error("Poller stopped correctly, but var's value hasn't been increased")
+		}
+
+	case <-time.After(time.Second * 10):
+		t.Errorf("Poller failed to stop after 5 seconds. Poller status: %v", poller.IsPolling())
+	}
+}
diff --git a/pkg/cloud/test_runs.go b/pkg/cloud/test_runs.go
new file mode 100644
index 0000000..b9bd573
--- /dev/null
+++ b/pkg/cloud/test_runs.go
@@ -0,0 +1,85 @@
+package cloud
+
+import (
+	"fmt"
+	"os"
+	"time"
+
+	"github.com/go-logr/logr"
+	"github.com/grafana/k6-operator/pkg/cloud/conn"
+	"github.com/sirupsen/logrus"
+	"go.k6.io/k6/cloudapi"
+	"go.k6.io/k6/lib/consts"
+)
+
+type TestRunPoller struct {
+	*conn.Poller
+
+	token     string
+	host      string
+	logger    logr.Logger
+	testRunCh chan string
+
+	Client *cloudapi.Client
+}
+
+func NewTestRunPoller(host, token string, logger logr.Logger) *TestRunPoller {
+	logrusLogger := &logrus.Logger{
+		Out:       os.Stdout,
+		Formatter: new(logrus.TextFormatter),
+		Hooks:     make(logrus.LevelHooks),
+		Level:     logrus.InfoLevel,
+	}
+
+	testRunsCh := make(chan string)
+
+	poller := conn.NewPoller(10 * time.Second)
+	poller.OnDone = func() {
+		close(testRunsCh)
+	}
+
+	testRunPoller := TestRunPoller{
+		Poller: poller,
+
+		token:     token,
+		host:      host,
+		logger:    logger,
+		testRunCh: testRunsCh,
+
+		Client: cloudapi.NewClient(logrusLogger, token, host, consts.Version, time.Duration(time.Minute)),
+	}
+
+	testRunPoller.Poller.OnInterval = func() {
+		list, err := testRunPoller.getTestRuns()
+		if err != nil {
+			logger.Error(err, "Failed to get test runs from k6 Cloud.")
+		} else {
+			logger.Info(fmt.Sprintf("Retrieved test runs: %+v", list))
+
+			for _, testRunId := range list {
+				testRunsCh <- testRunId
+			}
+		}
+	}
+
+	return &testRunPoller
+}
+
+func (poller *TestRunPoller) GetTestRuns() chan string {
+	return poller.testRunCh
+}
+
+func (poller *TestRunPoller) getTestRuns() ([]string, error) {
+	url := poller.host + "/get-tests" // TODO
+	req, err := poller.Client.NewRequest("GET", url, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var list testRunList
+	if err = poller.Client.Do(req, &list); err != nil {
+		return nil, err
+	}
+
+	return list.List, nil
+}
diff --git a/pkg/resources/jobs/runner_test.go b/pkg/resources/jobs/runner_test.go
index 8565c21..f755b25 100644
--- a/pkg/resources/jobs/runner_test.go
+++ b/pkg/resources/jobs/runner_test.go
@@ -55,7 +55,7 @@ func TestNewScriptVolumeClaim(t *testing.T) {
 		},
 	}
 
-	script, err := types.ParseScript(&k6)
+	script, err := k6.Script.Parse()
 	if err != nil {
 		t.Errorf("NewScript with ConfigMap errored, got: %v, want: %v", err, expectedOutcome)
 	}
@@ -81,7 +81,7 @@ func TestNewScriptConfigMap(t *testing.T) {
 		},
 	}
 
-	script, err := types.ParseScript(&k6)
+	script, err := k6.Script.Parse()
 	if err != nil {
 		t.Errorf("NewScript with ConfigMap errored, got: %v, want: %v", err, expectedOutcome)
 	}
@@ -105,7 +105,7 @@ func TestNewScriptLocalFile(t *testing.T) {
 		},
 	}
 
-	script, err := types.ParseScript(&k6)
+	script, err := k6.Script.Parse()
 	if err != nil {
 		t.Errorf("NewScript with LocalFile errored, got: %v, want: %v", err, expectedOutcome)
 	}
@@ -117,7 +117,7 @@ func TestNewScriptLocalFile(t *testing.T) {
 func TestNewScriptNoScript(t *testing.T) {
 	k6 := v1alpha1.K6Spec{}
 
-	script, err := types.ParseScript(&k6)
+	script, err := k6.Script.Parse()
 	if err == nil && script != nil {
 		t.Errorf("Expected Error from NewScript, got: %v, want: %v", err, errors.New("configMap, VolumeClaim or LocalFile not provided in script definition"))
 	}
diff --git a/pkg/testrun/plz.go b/pkg/testrun/plz.go
new file mode 100644
index 0000000..998751c
--- /dev/null
+++ b/pkg/testrun/plz.go
@@ -0,0 +1,51 @@
+package testrun
+
+import (
+	"fmt"
+
+	"github.com/grafana/k6-operator/api/v1alpha1"
+	"github.com/grafana/k6-operator/pkg/cloud"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestName(testRunId string) string {
+	return fmt.Sprintf("plz-test-%s", testRunId)
+}
+
+func NewPLZTestRun(plz *v1alpha1.PrivateLoadZone, trData cloud.TestRunData) *v1alpha1.K6 {
+	return &v1alpha1.K6{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      TestName(trData.TestRunId),
+			Namespace: plz.Namespace,
+		},
+		Spec: v1alpha1.K6Spec{
+			Runner: v1alpha1.Pod{
+				ServiceAccountName: plz.Spec.ServiceAccountName,
+				NodeSelector:       plz.Spec.NodeSelector,
+				Resources: corev1.ResourceRequirements{
+					Limits: plz.Spec.Resources,
+					// Requests will default to the Limits values.
+				},
+			},
+			Starter: v1alpha1.Pod{
+				ServiceAccountName: plz.Spec.ServiceAccountName,
+				NodeSelector:       plz.Spec.NodeSelector,
+			},
+			Parallelism: int32(trData.Instances),
+			Separate:    true,
+			// Arguments: "--out cloud",
+			Script: v1alpha1.K6Script{
+				ConfigMap: v1alpha1.K6Configmap{
+					Name: "crocodile-stress-test-short",
+					File: "test.js",
+				},
+			},
+			Cleanup: v1alpha1.Cleanup("post"),
+
+			TestRunID: trData.TestRunId,
+			Token:     plz.Spec.Token,
+		},
+	}
+}
diff --git a/pkg/types/conditions_test.go b/pkg/types/conditions_test.go
new file mode 100644
index 0000000..31cacdb
--- /dev/null
+++ b/pkg/types/conditions_test.go
@@ -0,0 +1,215 @@
+package types
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func Test_SetIfNewer(t *testing.T) {
+	var (
+		t1 = metav1.Now()
+		t2 = metav1.Time{
+			Time: t1.Add(time.Nanosecond),
+		}
+	)
+
+	testCases := []struct {
+		name                         string
+		oldConditions, newConditions *[]metav1.Condition
+		expected                     bool
+		expectedConditions           []metav1.Condition
+	}{
+		{
+			"empty case should be negative",
+			&[]metav1.Condition{},
+			&[]metav1.Condition{},
+			false,
+			[]metav1.Condition{},
+		},
+		{
+			"setting any new condition should be successful",
+			&[]metav1.Condition{},
+			&[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionUnknown,
+					LastTransitionTime: t1,
+				},
+			},
+			true,
+			[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionUnknown,
+					LastTransitionTime: t1,
+				},
+			},
+		},
+		{
+			"increasing timestamp without change of condition should be negative",
+			&[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionUnknown,
+					LastTransitionTime: t1,
+				},
+			},
+			&[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionUnknown,
+					LastTransitionTime: t2,
+				},
+			},
+			false,
+			[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionUnknown,
+					LastTransitionTime: t1,
+				},
+			},
+		},
+		{
+			"changing condition Unknown -> False should be successul if timestamp increased",
+			&[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionUnknown,
+					LastTransitionTime: t1,
+				},
+			},
+			&[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionFalse,
+					LastTransitionTime: t2,
+				},
+			},
+			true,
+			[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionFalse,
+					LastTransitionTime: t2,
+				},
+			},
+		},
+		{
+			"changing condition Unknown -> False should be negative if timestamp didn't change",
+			&[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionUnknown,
+					LastTransitionTime: t1,
+				},
+			},
+			&[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionFalse,
+					LastTransitionTime: t1,
+				},
+			},
+			false,
+			[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionUnknown,
+					LastTransitionTime: t1,
+				},
+			},
+		},
+		{
+			"changing condition Unknown -> True should be successul is timestamp increased",
+			&[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionUnknown,
+					LastTransitionTime: t1,
+				},
+			},
+			&[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionTrue,
+					LastTransitionTime: t2,
+				},
+			},
+			true,
+			[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionTrue,
+					LastTransitionTime: t2,
+				},
+			},
+		},
+		{
+			"changing condition True -> False should be successul if timestamp increased",
+			&[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionTrue,
+					LastTransitionTime: t1,
+				},
+			},
+			&[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionFalse,
+					LastTransitionTime: t2,
+				},
+			},
+			true,
+			[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionFalse,
+					LastTransitionTime: t2,
+				},
+			},
+		},
+		{
+			"changing condition True -> Unknown should be negative even if timestamp increased",
+			&[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionTrue,
+					LastTransitionTime: t1,
+				},
+			},
+			&[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionUnknown,
+					LastTransitionTime: t2,
+				},
+			},
+			false,
+			[]metav1.Condition{
+				metav1.Condition{
+					Type:               "cond",
+					Status:             metav1.ConditionTrue,
+					LastTransitionTime: t1,
+				},
+			},
+		},
+	}
+
+	for _, testCase := range testCases {
+		testCase := testCase
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+			isNewer := SetIfNewer(testCase.oldConditions, *testCase.newConditions, nil)
+			assert.Equal(t, testCase.expected, isNewer, "testCase", testCase)
+			assert.ElementsMatch(t, *testCase.oldConditions, testCase.expectedConditions, "testCase", testCase)
+		})
+
+	}
+}
+
+// TODO add test for callback in SetIfNewer
diff --git a/pkg/types/types_test.go b/pkg/types/types_test.go
index 07fc21d..9705b60 100644
--- a/pkg/types/types_test.go
+++ b/pkg/types/types_test.go
@@ -3,7 +3,6 @@ package types
 import (
 	"testing"
 
-	"github.com/grafana/k6-operator/api/v1alpha1"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -76,10 +75,7 @@ func Test_ParseCLI(t *testing.T) {
 		test := test
 		t.Run(test.name, func(t *testing.T) {
 			t.Parallel()
-			spec := v1alpha1.K6Spec{
-				Arguments: test.argLine,
-			}
-			cli := ParseCLI(&spec)
+			cli := ParseCLI(test.argLine)
 
 			assert.Equal(t, test.cli.ArchiveArgs, cli.ArchiveArgs)
 			assert.Equal(t, test.cli.HasCloudOut, cli.HasCloudOut)