mirror of
https://github.com/stefanprodan/podinfo.git
synced 2026-08-19 04:06:27 +00:00
Update Timoni CUE schemas
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
This commit is contained in:
@@ -97,4 +97,4 @@ swagger:
|
||||
|
||||
.PHONY: timoni-build
|
||||
timoni-build:
|
||||
@timoni build podinfo ./timoni/podinfo -f ./timoni/podinfo/test_values.cue
|
||||
@timoni build podinfo ./timoni/podinfo -f ./timoni/podinfo/debug_values.cue
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Image defines the schema for OCI image reference used in Kubernetes PodSpec container image.
|
||||
#Image: {
|
||||
@@ -22,6 +25,10 @@ import "strings"
|
||||
// Spec: https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests.
|
||||
digest!: string
|
||||
|
||||
// PullPolicy defines the pull policy for the image.
|
||||
// By default, it is set to IfNotPresent.
|
||||
pullPolicy: *"IfNotPresent" | "Always" | "Never"
|
||||
|
||||
// Reference is the image address computed from repository, tag and digest
|
||||
// in the format [REPOSITORY]:[TAG]@[DIGEST].
|
||||
reference: string
|
||||
@@ -42,3 +49,53 @@ import "strings"
|
||||
reference: "\(repository):latest"
|
||||
}
|
||||
}
|
||||
|
||||
// ImagePullSecret is a generator for Kubernetes Secrets of type kubernetes.io/dockerconfigjson.
|
||||
// Spec: https://kubernetes.io/docs/concepts/configuration/secret/#docker-config-secrets.
|
||||
#ImagePullSecret: {
|
||||
// Metadata is the Kubernetes object's metadata generated by Timoni.
|
||||
meta=metadata: #Metadata
|
||||
|
||||
// Registry is the hostname of the container registry in the format [HOST[:PORT_NUMBER]].
|
||||
registry!: string
|
||||
|
||||
// Username is the username used to authenticate to the container registry.
|
||||
username!: string
|
||||
|
||||
// Password is the password used to authenticate to the container registry.
|
||||
password!: string
|
||||
|
||||
// Optional suffix used to generate the Secret name.
|
||||
suffix: *"" | string
|
||||
|
||||
let auth = base64.Encode(null, username+":"+password)
|
||||
|
||||
// The object is a read-only struct that contains the generated
|
||||
// Kubernetes Secret of type kubernetes.io/dockerconfigjson.
|
||||
object: {
|
||||
apiVersion: "v1"
|
||||
kind: "Secret"
|
||||
type: "kubernetes.io/dockerconfigjson"
|
||||
metadata: {
|
||||
name: meta.name + suffix
|
||||
namespace: meta.namespace
|
||||
labels: meta.labels
|
||||
if meta.annotations != _|_ {
|
||||
annotations: meta.annotations
|
||||
}
|
||||
}
|
||||
stringData: {
|
||||
".dockerconfigjson": #"""
|
||||
{
|
||||
"auths": {
|
||||
"\#(registry)": {
|
||||
"username": "\#(username)",
|
||||
"password": "\#(password)",
|
||||
"auth": "\#(auth)"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""#
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2023 Stefan Prodan
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import "strings"
|
||||
|
||||
// InstanceName defines the schema for the name of a Timoni instance.
|
||||
// The instance name is used as a Kubernetes label value and must be 63 characters or less.
|
||||
#InstanceName: string & =~"^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$" & strings.MinRunes(1) & strings.MaxRunes(63)
|
||||
|
||||
// InstanceNamespace defines the schema for the namespace of a Timoni instance.
|
||||
// The instance namespace is used as a Kubernetes label value and must be 63 characters or less.
|
||||
#InstanceNamespace: string & =~"^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$" & strings.MinRunes(1) & strings.MaxRunes(63)
|
||||
|
||||
// InstanceOwnerReference defines the schema for Kubernetes labels used to denote ownership.
|
||||
#InstanceOwnerReference: {
|
||||
#Name: "instance.timoni.sh/name"
|
||||
#Namespace: "instance.timoni.sh/namespace"
|
||||
}
|
||||
|
||||
// InstanceModule defines the schema for the Module of a Timoni instance.
|
||||
#InstanceModule: {
|
||||
url: string & =~"^((oci|file)://.*)$"
|
||||
version: *"latest" | string
|
||||
digest?: string
|
||||
}
|
||||
@@ -5,6 +5,19 @@ package v1alpha1
|
||||
|
||||
import "strings"
|
||||
|
||||
// Annotations defines the schema for Kubernetes object metadata annotations.
|
||||
#Annotations: {[string & strings.MaxRunes(253)]: string}
|
||||
|
||||
// Labels defines the schema for Kubernetes object metadata labels.
|
||||
#Labels: {[string & strings.MaxRunes(253)]: string & =~"^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$" & strings.MaxRunes(63)}
|
||||
|
||||
#StdLabelName: "app.kubernetes.io/name"
|
||||
#StdLabelVersion: "app.kubernetes.io/version"
|
||||
#StdLabelPartOf: "app.kubernetes.io/part-of"
|
||||
#StdLabelManagedBy: "app.kubernetes.io/managed-by"
|
||||
#StdLabelComponent: "app.kubernetes.io/component"
|
||||
#StdLabelInstance: "app.kubernetes.io/instance"
|
||||
|
||||
// Metadata defines the schema for Kubernetes object metadata.
|
||||
#Metadata: {
|
||||
// Version should be in the strict semver format. Is required when creating resources.
|
||||
@@ -13,24 +26,90 @@ import "strings"
|
||||
// Name must be unique within a namespace. Is required when creating resources.
|
||||
// Name is primarily intended for creation idempotence and configuration definition.
|
||||
// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names
|
||||
name!: string & =~"^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$" & strings.MaxRunes(63)
|
||||
name!: #InstanceName
|
||||
|
||||
// Namespace defines the space within which each name must be unique.
|
||||
// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces
|
||||
namespace!: string & =~"^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$" & strings.MaxRunes(63)
|
||||
namespace!: #InstanceNamespace
|
||||
|
||||
// Annotations is an unstructured key value map stored with a resource that may be
|
||||
// set to store and retrieve arbitrary metadata.
|
||||
// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations
|
||||
annotations?: {[string & =~"^(([A-Za-z0-9][-A-Za-z0-9_./]*)?[A-Za-z0-9])?$" & strings.MaxRunes(63)]: string}
|
||||
annotations?: #Annotations
|
||||
|
||||
// Map of string keys and values that can be used to organize and categorize (scope and select) objects.
|
||||
// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels
|
||||
labels: {[string & =~"^(([A-Za-z0-9][-A-Za-z0-9_./]*)?[A-Za-z0-9])?$" & strings.MaxRunes(63)]: string & =~"^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$" & strings.MaxRunes(63)}
|
||||
labels: #Labels
|
||||
|
||||
// Standard Kubernetes labels: app name and version.
|
||||
// Standard Kubernetes labels: app name, version and managed-by.
|
||||
labels: {
|
||||
"app.kubernetes.io/name": name
|
||||
"app.kubernetes.io/version": #Version
|
||||
"\(#StdLabelName)": name
|
||||
"\(#StdLabelVersion)": #Version
|
||||
"\(#StdLabelManagedBy)": "timoni"
|
||||
}
|
||||
|
||||
// LabelSelector selects Pods based on the app.kubernetes.io/name label.
|
||||
#LabelSelector: #Labels & {
|
||||
"\(#StdLabelName)": name
|
||||
}
|
||||
}
|
||||
|
||||
// MetaComponent generates the Kubernetes object metadata for a module namespaced component.
|
||||
// The metadata.name is composed of the instance name and the component name.
|
||||
// The metadata.labels contain the app.kubernetes.io/component label.
|
||||
#MetaComponent: {
|
||||
// Meta is the Kubernetes object's metadata generated by Timoni.
|
||||
#Meta!: #Metadata
|
||||
|
||||
// Component is the name of the component used
|
||||
// as a suffix for the generate object name.
|
||||
#Component!: string & strings.MaxRunes(30)
|
||||
|
||||
name: #Meta.name + "-" + #Component
|
||||
namespace: #Meta.namespace
|
||||
|
||||
labels: #Meta.labels
|
||||
labels: "\(#StdLabelComponent)": #Component
|
||||
|
||||
annotations?: #Annotations
|
||||
if #Meta.annotations != _|_ {
|
||||
annotations: #Meta.annotations
|
||||
}
|
||||
|
||||
// LabelSelector selects Pods based on the app.kubernetes.io/name
|
||||
// and app.kubernetes.io/component labels.
|
||||
#LabelSelector: #Labels & {
|
||||
"\(#StdLabelComponent)": #Component
|
||||
"\(#StdLabelName)": #Meta.name
|
||||
}
|
||||
}
|
||||
|
||||
// MetaClusterComponent generates the Kubernetes object metadata for a module non-namespaced component.
|
||||
// The metadata.name is composed of the instance name and the component name.
|
||||
// The metadata.namespace is unset.
|
||||
// The metadata.labels contain the app.kubernetes.io/component label.
|
||||
#MetaClusterComponent: {
|
||||
// Meta is the Kubernetes object's metadata generated by Timoni.
|
||||
#Meta!: #Metadata
|
||||
|
||||
// Component is the name of the component used
|
||||
// as a suffix for the generate object name.
|
||||
#Component!: string & strings.MaxRunes(30)
|
||||
|
||||
name: #Meta.name + "-" + #Component
|
||||
|
||||
labels: #Meta.labels
|
||||
labels: "\(#StdLabelComponent)": #Component
|
||||
|
||||
annotations?: #Annotations
|
||||
if #Meta.annotations != _|_ {
|
||||
annotations: #Meta.annotations
|
||||
}
|
||||
|
||||
// LabelSelector selects Pods based on the app.kubernetes.io/name
|
||||
// and app.kubernetes.io/component labels.
|
||||
#LabelSelector: #Labels & {
|
||||
"\(#StdLabelComponent)": #Component
|
||||
"\(#StdLabelName)": #Meta.name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2023 Stefan Prodan
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CPUQuantity is a string that is validated as a quantity of CPU, such as 100m or 2000m.
|
||||
#CPUQuantity: string & =~"^[1-9]\\d*m$"
|
||||
|
||||
// MemoryQuantity is a string that is validated as a quantity of memory, such as 128Mi or 2Gi.
|
||||
#MemoryQuantity: string & =~"^[1-9]\\d*(Mi|Gi)$"
|
||||
|
||||
// ResourceRequirement defines the schema for the CPU and Memory resource requirements.
|
||||
#ResourceRequirement: {
|
||||
cpu?: #CPUQuantity
|
||||
memory?: #MemoryQuantity
|
||||
}
|
||||
|
||||
// ResourceRequirements defines the schema for the compute resource requirements of a container.
|
||||
// More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/.
|
||||
#ResourceRequirements: {
|
||||
// Limits describes the maximum amount of compute resources allowed.
|
||||
limits?: #ResourceRequirement
|
||||
|
||||
// Requests describes the minimum amount of compute resources required.
|
||||
// Requests cannot exceed Limits.
|
||||
requests?: #ResourceRequirement & {
|
||||
if limits != _|_ {
|
||||
if limits.cpu != _|_ {
|
||||
_lc: strconv.Atoi(strings.Split(limits.cpu, "m")[0])
|
||||
_rc: strconv.Atoi(strings.Split(requests.cpu, "m")[0])
|
||||
#cpu: int & >=_rc & _lc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,17 @@
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import "strings"
|
||||
|
||||
// Selector defines the schema for Kubernetes Pod label selector used in Deployments, Services, Jobs, etc.
|
||||
#Selector: {
|
||||
// Name must be unique within a namespace. Is required when creating resources.
|
||||
// Name is primarily intended for creation idempotence and configuration definition.
|
||||
// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names
|
||||
#Name!: string & =~"^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$" & strings.MinRunes(1) & strings.MaxRunes(63)
|
||||
#Name!: #InstanceName
|
||||
|
||||
// Map of string keys and values that can be used to organize and categorize (scope and select) objects.
|
||||
// More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels
|
||||
labels: {[string & =~"^(([A-Za-z0-9][-A-Za-z0-9_./]*)?[A-Za-z0-9])?$" & strings.MaxRunes(63)]: string & =~"^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$" & strings.MaxRunes(63)}
|
||||
labels: #Labels
|
||||
|
||||
// Standard Kubernetes label: app name.
|
||||
labels: "app.kubernetes.io/name": #Name
|
||||
labels: "\(#StdLabelName)": #Name
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright 2023 Stefan Prodan
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SemVer validates the input version string and extracts the major and minor version numbers.
|
||||
// When Minimum is set, the major and minor parts must be greater or equal to the minimum
|
||||
// or a validation error is returned.
|
||||
#SemVer: {
|
||||
// Input version string in strict semver format.
|
||||
#Version!: string & =~"^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$"
|
||||
|
||||
// Minimum is the minimum allowed MAJOR.MINOR version.
|
||||
#Minimum: *"0.0.0" | string & =~"^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$"
|
||||
|
||||
let minMajor = strconv.Atoi(strings.Split(#Minimum, ".")[0])
|
||||
let minMinor = strconv.Atoi(strings.Split(#Minimum, ".")[1])
|
||||
|
||||
major: int & >=minMajor
|
||||
major: strconv.Atoi(strings.Split(#Version, ".")[0])
|
||||
|
||||
minor: int & >=minMinor
|
||||
minor: strconv.Atoi(strings.Split(#Version, ".")[1])
|
||||
}
|
||||
@@ -7,45 +7,70 @@ import (
|
||||
|
||||
// Config defines the schema and defaults for the Instance values.
|
||||
#Config: {
|
||||
// UI setting
|
||||
// Podinfo optional UI setting.
|
||||
ui: {
|
||||
color: *"#34577c" | string
|
||||
message?: string
|
||||
backend?: string
|
||||
}
|
||||
|
||||
// Runtime version info
|
||||
// Runtime version info automatically set at apply-time.
|
||||
moduleVersion!: string
|
||||
kubeVersion!: string
|
||||
|
||||
// Metadata (common to all resources)
|
||||
// The minimum Kubernetes version to 1.20.
|
||||
clusterVersion: timoniv1.#SemVer & {#Version: kubeVersion, #Minimum: "1.20.0"}
|
||||
|
||||
// Kubernetes metadata common to all resources.
|
||||
metadata: timoniv1.#Metadata & {#Version: moduleVersion}
|
||||
|
||||
// Label selector (common to all resources)
|
||||
// Label selector common to all resources.
|
||||
selector: timoniv1.#Selector & {#Name: metadata.name}
|
||||
|
||||
// Deployment
|
||||
// The number of pods replicas.
|
||||
// By default, the number of replicas is 1.
|
||||
replicas: *1 | int & >=0
|
||||
|
||||
// Pod
|
||||
podAnnotations?: {[ string]: string}
|
||||
// The image allows setting the container image repository,
|
||||
// tag, digest and pull policy.
|
||||
// The default image repository and tag is set in `values.cue`.
|
||||
image!: timoniv1.#Image
|
||||
|
||||
// The resources allows setting the container resource requirements.
|
||||
// By default, the container requests 10m CPU and 32Mi memory.
|
||||
resources: timoniv1.#ResourceRequirements & {
|
||||
requests: {
|
||||
cpu: *"10m" | timoniv1.#CPUQuantity
|
||||
memory: *"32Mi" | timoniv1.#MemoryQuantity
|
||||
}
|
||||
}
|
||||
|
||||
// The securityContext allows setting the container security context.
|
||||
securityContext?: corev1.#SecurityContext
|
||||
|
||||
// Pod optinal settings.
|
||||
podAnnotations?: {[string]: string}
|
||||
podSecurityContext?: corev1.#PodSecurityContext
|
||||
imagePullSecrets?: [...corev1.LocalObjectReference]
|
||||
tolerations?: [ ...corev1.#Toleration]
|
||||
affinity?: corev1.#Affinity
|
||||
tolerations?: [...corev1.#Toleration]
|
||||
topologySpreadConstraints?: [...corev1.#TopologySpreadConstraint]
|
||||
|
||||
// Container
|
||||
image: timoniv1.#Image
|
||||
imagePullPolicy: *"IfNotPresent" | string
|
||||
resources?: corev1.#ResourceRequirements
|
||||
securityContext?: corev1.#SecurityContext
|
||||
// Pod affinity rules, by default, pods are scheduled on Linux nodes.
|
||||
affinity: *{
|
||||
nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: [{
|
||||
matchExpressions: [{
|
||||
key: corev1.#LabelOSStable
|
||||
operator: "In"
|
||||
values: ["linux"]
|
||||
}]
|
||||
}]
|
||||
} | corev1.#Affinity
|
||||
|
||||
// Service
|
||||
service: {
|
||||
port: *80 | int & >0 & <=65535
|
||||
annotations?: {[ string]: string}
|
||||
labels?: {[ string]: string}
|
||||
port: *80 | int & >0 & <=65535
|
||||
annotations?: timoniv1.#Annotations
|
||||
labels?: timoniv1.#Labels
|
||||
}
|
||||
|
||||
// HorizontalPodAutoscaler (optional)
|
||||
@@ -59,12 +84,12 @@ import (
|
||||
|
||||
// Ingress (optional)
|
||||
ingress: {
|
||||
enabled: *false | bool
|
||||
tls: *false | bool
|
||||
host: *"podinfo.local" | string
|
||||
annotations?: {[ string]: string}
|
||||
labels?: {[ string]: string}
|
||||
className?: string
|
||||
enabled: *false | bool
|
||||
tls: *false | bool
|
||||
host: *"podinfo.local" | string
|
||||
className?: string
|
||||
annotations?: timoniv1.#Annotations
|
||||
labels?: timoniv1.#Labels
|
||||
}
|
||||
|
||||
// ServiceMonitor (optional)
|
||||
@@ -91,9 +116,9 @@ import (
|
||||
config: #Config
|
||||
|
||||
objects: {
|
||||
"\(config.metadata.name)-sa": #ServiceAccount & {_config: config}
|
||||
"\(config.metadata.name)-svc": #Service & {_config: config}
|
||||
"\(config.metadata.name)-deploy": #Deployment & {_config: config}
|
||||
"\(config.metadata.name)-sa": #ServiceAccount & {_config: config}
|
||||
"\(config.metadata.name)-svc": #Service & {_config: config}
|
||||
"\(config.metadata.name)-deploy": #Deployment & {_config: config}
|
||||
|
||||
if config.autoscaling.enabled {
|
||||
"\(config.metadata.name)-hpa": #HorizontalPodAutoscaler & {_config: config}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
apiVersion: "apps/v1"
|
||||
kind: "Deployment"
|
||||
metadata: _config.metadata
|
||||
spec: appsv1.#DeploymentSpec & {
|
||||
spec: appsv1.#DeploymentSpec & {
|
||||
if !_config.autoscaling.enabled {
|
||||
replicas: _config.replicas
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import (
|
||||
{
|
||||
name: _config.metadata.name
|
||||
image: _config.image.reference
|
||||
imagePullPolicy: _config.imagePullPolicy
|
||||
imagePullPolicy: _config.image.pullPolicy
|
||||
ports: [
|
||||
{
|
||||
name: "http"
|
||||
|
||||
@@ -13,20 +13,20 @@ import (
|
||||
_config: #Config
|
||||
apiVersion: "batch/v1"
|
||||
kind: "Job"
|
||||
metadata: name: "\(_config.metadata.name)-test"
|
||||
metadata: namespace: _config.metadata.namespace
|
||||
metadata: labels: _config.metadata.labels
|
||||
metadata: timoniv1.#MetaComponent & {
|
||||
#Meta: _config.metadata
|
||||
#Component: "test"
|
||||
}
|
||||
metadata: annotations: timoniv1.Action.Force
|
||||
spec: batchv1.#JobSpec & {
|
||||
template: corev1.#PodTemplateSpec & {
|
||||
metadata: labels: _config.metadata.labels
|
||||
let _checksum = uuid.SHA1(uuid.ns.DNS, yaml.Marshal(_config))
|
||||
metadata: annotations: "timoni.sh/checksum": "\(_checksum)"
|
||||
spec: {
|
||||
containers: [{
|
||||
name: "curl"
|
||||
image: _config.test.image.reference
|
||||
imagePullPolicy: _config.imagePullPolicy
|
||||
imagePullPolicy: _config.test.image.pullPolicy
|
||||
command: [
|
||||
"curl",
|
||||
"-v",
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
templates "timoni.sh/podinfo/templates"
|
||||
)
|
||||
|
||||
@@ -39,16 +36,12 @@ timoni: {
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce minimum Kubernetes version.
|
||||
kubeMinorVersion: int & >=23
|
||||
kubeMinorVersion: strconv.Atoi(strings.Split(instance.config.kubeVersion, ".")[1])
|
||||
|
||||
// Pass Kubernetes resources outputted by the instance
|
||||
// to Timoni's multi-step apply.
|
||||
apply: app: [ for obj in instance.objects {obj}]
|
||||
apply: app: [for obj in instance.objects {obj}]
|
||||
|
||||
// Conditionally run tests after an install or upgrade.
|
||||
if instance.config.test.enabled {
|
||||
apply: test: [ for obj in instance.tests {obj}]
|
||||
apply: test: [for obj in instance.tests {obj}]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user