From 8a17f27562bb863a8f3d27d38898ff84088d59c3 Mon Sep 17 00:00:00 2001 From: Rahul Anand Date: Mon, 10 Jan 2022 18:21:33 +0530 Subject: [PATCH 1/4] Makefile: save crds and rbacs in a manifest file --- Makefile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index b075598a..48291105 100644 --- a/Makefile +++ b/Makefile @@ -71,10 +71,14 @@ all: build help: ## Display this help. @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-30s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) +# Operator manifests (RBAC & CRD) +OPERATOR_MANIFESTS ?= $(PROJECT_DIR)/config/install/manifests.yaml + ##@ Development -manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. - $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases +manifests: controller-gen kustomize ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. + $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases && \ + $(KUSTOMIZE) build config/install > $(OPERATOR_MANIFESTS) generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." From 3c51ea75ae0f367f1ef1681ffeaeddbfe471f6e6 Mon Sep 17 00:00:00 2001 From: Rahul Anand Date: Mon, 10 Jan 2022 18:22:23 +0530 Subject: [PATCH 2/4] config/install: initialize manifests for crds and rbacs --- config/install/kustomization.yaml | 10 + config/install/manifests.yaml | 317 ++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 config/install/kustomization.yaml create mode 100644 config/install/manifests.yaml diff --git a/config/install/kustomization.yaml b/config/install/kustomization.yaml new file mode 100644 index 00000000..88aa462b --- /dev/null +++ b/config/install/kustomization.yaml @@ -0,0 +1,10 @@ +# Adds namespace to all resources. +namespace: limitador-operator + +# Value of this field is prepended to the +# names of all resources, e.g. a deployment named +namePrefix: limitador-operator + +bases: +- ../crd +- ../rbac \ No newline at end of file diff --git a/config/install/manifests.yaml b/config/install/manifests.yaml new file mode 100644 index 00000000..6cfb6a90 --- /dev/null +++ b/config/install/manifests.yaml @@ -0,0 +1,317 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.7.0 + creationTimestamp: null + name: limitadors.limitador.kuadrant.io +spec: + group: limitador.kuadrant.io + names: + kind: Limitador + listKind: LimitadorList + plural: limitadors + singular: limitador + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Limitador is the Schema for the limitadors 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: LimitadorSpec defines the desired state of Limitador + properties: + replicas: + type: integer + version: + type: string + type: object + status: + description: LimitadorStatus defines the observed state of Limitador + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.7.0 + creationTimestamp: null + name: ratelimits.limitador.kuadrant.io +spec: + group: limitador.kuadrant.io + names: + kind: RateLimit + listKind: RateLimitList + plural: ratelimits + singular: ratelimit + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: RateLimit is the Schema for the ratelimits 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: RateLimitSpec defines the desired state of RateLimit + properties: + conditions: + items: + type: string + type: array + max_value: + type: integer + namespace: + type: string + seconds: + type: integer + variables: + items: + type: string + type: array + required: + - conditions + - max_value + - namespace + - seconds + - variables + type: object + status: + description: RateLimitStatus defines the observed state of RateLimit + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: limitador-operatorcontroller-manager + namespace: limitador-operator +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: limitador-operatorleader-election-role + namespace: limitador-operator +rules: +- apiGroups: + - "" + - coordination.k8s.io + resources: + - configmaps + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + creationTimestamp: null + name: limitador-operatormanager-role +rules: +- apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - update + - watch +- apiGroups: + - "" + resources: + - services + verbs: + - create + - delete + - get + - list + - update + - watch +- apiGroups: + - limitador.kuadrant.io + resources: + - limitadors + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - limitador.kuadrant.io + resources: + - limitadors/finalizers + verbs: + - update +- apiGroups: + - limitador.kuadrant.io + resources: + - limitadors/status + verbs: + - get + - patch + - update +- apiGroups: + - limitador.kuadrant.io + resources: + - ratelimits + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - limitador.kuadrant.io + resources: + - ratelimits/finalizers + verbs: + - update +- apiGroups: + - limitador.kuadrant.io + resources: + - ratelimits/status + verbs: + - get + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: limitador-operatormetrics-reader +rules: +- nonResourceURLs: + - /metrics + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: limitador-operatorproxy-role +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: limitador-operatorleader-election-rolebinding + namespace: limitador-operator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: limitador-operatorleader-election-role +subjects: +- kind: ServiceAccount + name: limitador-operatorcontroller-manager + namespace: limitador-operator +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: limitador-operatormanager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: limitador-operatormanager-role +subjects: +- kind: ServiceAccount + name: limitador-operatorcontroller-manager + namespace: limitador-operator +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: limitador-operatorproxy-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: limitador-operatorproxy-role +subjects: +- kind: ServiceAccount + name: limitador-operatorcontroller-manager + namespace: limitador-operator +--- +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + name: limitador-operatorcontroller-manager-metrics-service + namespace: limitador-operator +spec: + ports: + - name: https + port: 8443 + targetPort: https + selector: + control-plane: controller-manager From 9dd9211aa1bb0094add29e714ee2929328bd8e3e Mon Sep 17 00:00:00 2001 From: Rahul Anand Date: Mon, 10 Jan 2022 18:48:37 +0530 Subject: [PATCH 3/4] add manifest file and script for deployment --- Makefile | 20 +- config/deploy/manfiests.yaml | 405 +++++++++++++++++++++++++++++++++++ 2 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 config/deploy/manfiests.yaml diff --git a/Makefile b/Makefile index 48291105..efe19632 100644 --- a/Makefile +++ b/Makefile @@ -24,19 +24,26 @@ BUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL) endif BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) +# Address of the container registry +REGISTRY = quay.io + +# Organization in container resgistry +ORG ?= kuadrant + # IMAGE_TAG_BASE defines the docker.io namespace and part of the image name for remote images. # This variable is used to construct full image tags for bundle and catalog images. # # For example, running 'make bundle-build bundle-push catalog-build catalog-push' will build and push both # quay.io/kuadrant/limitador-operator-bundle:$VERSION and quay.io/kuadrant/limitador-operator-catalog:$VERSION. -IMAGE_TAG_BASE ?= quay.io/kuadrant/limitador-operator +IMAGE_TAG_BASE ?= $(REGISTRY)/$(ORG)/limitador-operator # BUNDLE_IMG defines the image:tag used for the bundle. # You can use it as an arg. (E.g make bundle-build BUNDLE_IMG=/:) BUNDLE_IMG ?= $(IMAGE_TAG_BASE)-bundle:v$(VERSION) # Image URL to use all building/pushing image targets -IMG ?= quay.io/kuadrant/limitador-operator:latest +DEFAULT_IMG ?= $(IMAGE_TAG_BASE):latest +IMG ?= $(DEFAULT_IMG) # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. ENVTEST_K8S_VERSION = 1.22 @@ -150,6 +157,15 @@ rm -rf $$TMP_DIR ;\ } endef +DEPLOYMENT_DIR = $(PROJECT_DIR)/config/deploy +.PHONY: deploy-manifest +deploy-manifest: + mkdir -p $(DEPLOYMENT_DIR) + cd $(PROJECT_DIR)/config/manager && $(KUSTOMIZE) edit set image controller=$(IMG) ;\ + cd $(PROJECT_DIR) && $(KUSTOMIZE) build config/default >> $(DEPLOYMENT_DIR)/manfiests.yaml + # clean up + cd $(PROJECT_DIR)/config/manager && $(KUSTOMIZE) edit set image controller=${DEFAULT_IMG} + OPERATOR_SDK = $(shell pwd)/bin/operator-sdk OPERATOR_SDK_VERSION = v1.15.0 operator-sdk: ## Download operator-sdk locally if necessary. diff --git a/config/deploy/manfiests.yaml b/config/deploy/manfiests.yaml new file mode 100644 index 00000000..c86810ef --- /dev/null +++ b/config/deploy/manfiests.yaml @@ -0,0 +1,405 @@ +apiVersion: v1 +kind: Namespace +metadata: + labels: + control-plane: controller-manager + name: limitador-operator-system +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.7.0 + creationTimestamp: null + name: limitadors.limitador.kuadrant.io +spec: + group: limitador.kuadrant.io + names: + kind: Limitador + listKind: LimitadorList + plural: limitadors + singular: limitador + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Limitador is the Schema for the limitadors 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: LimitadorSpec defines the desired state of Limitador + properties: + replicas: + type: integer + version: + type: string + type: object + status: + description: LimitadorStatus defines the observed state of Limitador + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.7.0 + creationTimestamp: null + name: ratelimits.limitador.kuadrant.io +spec: + group: limitador.kuadrant.io + names: + kind: RateLimit + listKind: RateLimitList + plural: ratelimits + singular: ratelimit + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: RateLimit is the Schema for the ratelimits 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: RateLimitSpec defines the desired state of RateLimit + properties: + conditions: + items: + type: string + type: array + max_value: + type: integer + namespace: + type: string + seconds: + type: integer + variables: + items: + type: string + type: array + required: + - conditions + - max_value + - namespace + - seconds + - variables + type: object + status: + description: RateLimitStatus defines the observed state of RateLimit + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: limitador-operator-controller-manager + namespace: limitador-operator-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: limitador-operator-leader-election-role + namespace: limitador-operator-system +rules: +- apiGroups: + - "" + - coordination.k8s.io + resources: + - configmaps + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + creationTimestamp: null + name: limitador-operator-manager-role +rules: +- apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - update + - watch +- apiGroups: + - "" + resources: + - services + verbs: + - create + - delete + - get + - list + - update + - watch +- apiGroups: + - limitador.kuadrant.io + resources: + - limitadors + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - limitador.kuadrant.io + resources: + - limitadors/finalizers + verbs: + - update +- apiGroups: + - limitador.kuadrant.io + resources: + - limitadors/status + verbs: + - get + - patch + - update +- apiGroups: + - limitador.kuadrant.io + resources: + - ratelimits + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - limitador.kuadrant.io + resources: + - ratelimits/finalizers + verbs: + - update +- apiGroups: + - limitador.kuadrant.io + resources: + - ratelimits/status + verbs: + - get + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: limitador-operator-metrics-reader +rules: +- nonResourceURLs: + - /metrics + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: limitador-operator-proxy-role +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: limitador-operator-leader-election-rolebinding + namespace: limitador-operator-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: limitador-operator-leader-election-role +subjects: +- kind: ServiceAccount + name: limitador-operator-controller-manager + namespace: limitador-operator-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: limitador-operator-manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: limitador-operator-manager-role +subjects: +- kind: ServiceAccount + name: limitador-operator-controller-manager + namespace: limitador-operator-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: limitador-operator-proxy-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: limitador-operator-proxy-role +subjects: +- kind: ServiceAccount + name: limitador-operator-controller-manager + namespace: limitador-operator-system +--- +apiVersion: v1 +data: + controller_manager_config.yaml: | + apiVersion: controller-runtime.sigs.k8s.io/v1alpha1 + kind: ControllerManagerConfig + health: + healthProbeBindAddress: :8081 + metrics: + bindAddress: 127.0.0.1:8080 + webhook: + port: 9443 + leaderElection: + leaderElect: true + resourceName: 3745a16e.kuadrant.io +kind: ConfigMap +metadata: + name: limitador-operator-manager-config + namespace: limitador-operator-system +--- +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + name: limitador-operator-controller-manager-metrics-service + namespace: limitador-operator-system +spec: + ports: + - name: https + port: 8443 + targetPort: https + selector: + control-plane: controller-manager +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + control-plane: controller-manager + name: limitador-operator-controller-manager + namespace: limitador-operator-system +spec: + replicas: 1 + selector: + matchLabels: + control-plane: controller-manager + template: + metadata: + labels: + control-plane: controller-manager + spec: + containers: + - args: + - --secure-listen-address=0.0.0.0:8443 + - --upstream=http://127.0.0.1:8080/ + - --logtostderr=true + - --v=10 + image: gcr.io/kubebuilder/kube-rbac-proxy:v0.8.0 + name: kube-rbac-proxy + ports: + - containerPort: 8443 + name: https + - args: + - --health-probe-bind-address=:8081 + - --metrics-bind-address=127.0.0.1:8080 + - --leader-elect + command: + - /manager + image: quay.io/kuadrant/limitador-operator:latest + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + name: manager + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + limits: + cpu: 100m + memory: 30Mi + requests: + cpu: 100m + memory: 20Mi + securityContext: + allowPrivilegeEscalation: false + securityContext: + runAsNonRoot: true + serviceAccountName: limitador-operator-controller-manager + terminationGracePeriodSeconds: 10 From b070d37e6c889f66f2b54de551f1d4145ab25bc6 Mon Sep 17 00:00:00 2001 From: Rahul Anand Date: Tue, 11 Jan 2022 15:16:18 +0530 Subject: [PATCH 4/4] add make target and CI job to verify generated manifests --- .github/workflows/go.yml | 12 ++++++++++++ Makefile | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index d08290b8..fec985b5 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -33,3 +33,15 @@ jobs: - uses: actions/checkout@v2 - name: Run the tests run: make test + verify-manifests: + name: Verify manifests + runs-on: ubuntu-latest + steps: + - name: Set up Go 1.16.x + uses: actions/setup-go@v2 + with: + go-version: 1.16.x + id: go + - uses: actions/checkout@v2 + - name: Verify manifests + run: make verify-manifests diff --git a/Makefile b/Makefile index efe19632..b46093ff 100644 --- a/Makefile +++ b/Makefile @@ -86,6 +86,7 @@ OPERATOR_MANIFESTS ?= $(PROJECT_DIR)/config/install/manifests.yaml manifests: controller-gen kustomize ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases && \ $(KUSTOMIZE) build config/install > $(OPERATOR_MANIFESTS) + $(MAKE) deploy-manifest generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." @@ -161,6 +162,7 @@ DEPLOYMENT_DIR = $(PROJECT_DIR)/config/deploy .PHONY: deploy-manifest deploy-manifest: mkdir -p $(DEPLOYMENT_DIR) + rm $(DEPLOYMENT_DIR)/manfiests.yaml || true cd $(PROJECT_DIR)/config/manager && $(KUSTOMIZE) edit set image controller=$(IMG) ;\ cd $(PROJECT_DIR) && $(KUSTOMIZE) build config/default >> $(DEPLOYMENT_DIR)/manfiests.yaml # clean up @@ -256,3 +258,13 @@ local-cleanup: kind ## Clean up local kind cluster .PHONY: local-setup-kind local-setup-kind: kind ## Create kind cluster $(KIND) create cluster --name $(KIND_CLUSTER_NAME) + +##@ Verify + +## Targets to verify actions that generate/modify code have been executed and output committed + +.PHONY: verify-manifests +verify-manifests: manifests ## Verify manifests update. + git diff --exit-code ./config + [ -z "$$(git ls-files --other --exclude-standard --directory --no-empty-directory ./config)" ] +