diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fd6c5cb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# Repository Guidelines + +This guide explains how to contribute to the Spin Helm recipes with minimal friction. Keep changes small, tested, and documented so others can reproduce them. + +## Project Structure & Module Organization +- `nats/`: Helm values and kustomize post-renderer for the NATS chart; `values.yaml` for defaults and `tls-acme-values.yaml` for certificate setup. `kustomize/` patches StatefulSets/Deployments with `SPIN_UID`/`SPIN_GID`. +- `tls-acme/`: Standalone Helm chart (`Chart.yaml`, `templates/`, `values.yaml`) for ACME-driven TLS issuance and renewals on Spin. +- `scripts/`: Utility scripts (e.g., `scripts/mdl.sh` for markdownlint autofix). +- Repo configs: `.pre-commit-config.yaml`, `.yamllint.yml`, `.markdownlint.rb` define lint rules; align contributions to them. + +## Build, Test, and Development Commands +- `pre-commit run --all-files` to run markdownlint, yamllint, and trailing whitespace checks locally. +- `yamllint .` to validate YAML with the repo config (line length 120, strict indentation). +- `./scripts/mdl.sh` to autofix Markdown formatting before review. +- `helm lint tls-acme` to sanity-check the TLS chart; add `-f ` as needed. +- For NATS overlays, render and patch locally: `cd nats/kustomize && helm template nats/nats -f ../values.yaml --post-renderer ./kustomize.sh`. + +## Coding Style & Naming Conventions +- YAML: 2-space indentation, descriptive keys, keep `*-values.yaml` file names and placeholder tokens (`RUN_AS_USER_PLACEHOLDER`, `FS_GROUP_PLACEHOLDER`) intact for scripted substitution. +- Markdown: respect `.markdownlint.rb` (ordered lists, generous line length). Prefer short headings and actionable steps. +- Shell: keep scripts POSIX/Bash compatible, check for required env vars (`SPIN_UID`, `SPIN_GID`) before use. + +## Testing Guidelines +- Chart changes: run `helm lint` and, when altering templates, `helm template ... | kubectl kustomize ./nats/kustomize` to verify patches apply cleanly. +- Functional checks occur on Spin: follow the per-directory READMEs to install/upgrade releases, then validate via `kubectl` execs (e.g., `nats rtt`) or ingress reachability. +- Add brief notes in PRs on what was verified (commands + namespace/cluster used), especially for TLS issuance runs. + +## Commit & Pull Request Guidelines +- Commits: imperative mood with scope when helpful (e.g., `nats: adjust websocket ingress whitelist`); group related changes. +- PRs: include a summary of changes, linked issues (if any), values files touched, commands run, and relevant logs or screenshots for install/upgrade attempts. +- Avoid committing secrets or cluster-specific kubeconfigs; keep sensitive values in local overrides and document placeholders instead. diff --git a/tls-acme/README.md b/tls-acme/README.md index 98dcb2c..ec2379b 100644 --- a/tls-acme/README.md +++ b/tls-acme/README.md @@ -59,8 +59,10 @@ This helm chart takes consideration of two different usage cases. The installati ```bash cd tls-acme -./prepare-values.sh -helm lint . +cp prepare-values.yaml.example prepare-values.yaml +# edit prepare-values.yaml +python3 prepare-values.py +helm lint . -f values.yaml helm install -n -f values.yaml acmecron . ``` @@ -101,20 +103,27 @@ tls-acme #### Customize values for chart installation -Edit `values_template.yaml` by setting the placeholder values (including -`useCase` for case1 vs case2), then run `./prepare-values.sh` to generate -`values.yaml`. - -- `` -- `` -- `` -- `` -- `` -- `` -- `` (can be `development.svc.spin.nersc.org` or `production.svc.spin.nersc.org`) -- `existing-websrv` -- `pvc-existing-webroot` -- change `webServer.existing` field from `false` to `true` +Copy `prepare-values.yaml.example` to `prepare-values.yaml`, update the values there, +and then run `python3 prepare-values.py` to generate `values.yaml` from `values_template.yaml`. + +- `nersc_user_id` +- `nersc_user_group` +- `user_domains` as a YAML list of user-facing domains +- `email` +- `service_port` +- `ingress_name` +- `cluster` (can be `development.svc.spin.nersc.org` or `production.svc.spin.nersc.org`) +- `use_case` (`case1` for an existing web server, `case2` to create one) +- `webserver_existing` only if you want to override the value derived from `use_case` + +For multiple CNAMEs, list all of them in `user_domains`, for example: + +```yaml +user_domains: + - app.example.org + - www.example.org + - api.example.org +``` #### Install the chart @@ -130,8 +139,8 @@ helm install -n -f values.yaml acmecron . The results of this installation are: 1. A self-generated TLS certificate saved into a secret named `tls-cert`; -2. A new ingress in the namespace, with rules for each of the domains, including the default Spin domain, pointing to the existing web server and its http port; the ingress will also use the self-generated certificate for all the domains; -3. A cronjob which runs every two months to reuqest/renew a TLS certificate, and repalce the self-generated TLS certificate with it. The requested certificate will include all the listed domains in the ingress. +2. A new ingress in the namespace, with rules for each of the domains, including the default Spin domain, pointing to the existing web server and its http port; the default Spin domain is intentionally not listed in the certificate SANs, so accessing it directly will show a certificate warning. +3. A cronjob which runs every two months to reuqest/renew a TLS certificate, and repalce the self-generated TLS certificate with it. The requested certificate will include only the user-facing domains listed in `ingress.userDomains`; the internal Spin hostname is added to the ingress automatically but excluded from the certificate. #### Post installation setup (1) @@ -164,21 +173,13 @@ This is applicatable to the usage cases like: #### Installation and inspection -Similar as _Case 1_ above, but change the following in `values_template.yaml`, -set `useCase` to `case2`, and then re-run `./prepare-values.sh`: - -- `` -- `` -- `` -- `` -- `` to `8080` -- `` -- `` (can be `development.svc.spin.nersc.org` or `production.svc.spin.nersc.org`) +Similar as _Case 1_ above, but set `use_case` to `case2` in `prepare-values.yaml`, +update the other config values as needed, and then re-run `python3 prepare-values.py`. Different than _Case 1_, this installation of the chart will result in: 1. A deployment of a simple web server, running on port 8080 internally; -2. A new ingress in the namespace, pointing all of the domains, including the default Spin domain, to the newly created web server and its port 8080. +2. A new ingress in the namespace, pointing all of the domains, including the default Spin domain, to the newly created web server and its port 8080. The default Spin domain remains outside the certificate SANs on purpose, so it shows a certificate warning if accessed directly. #### Post installation setup (2) @@ -190,8 +191,8 @@ During, the future cronjob runs, your modified ingress will be saved first, chan ### Upgrade or uninstall the chart -If you made changes to `values_template.yaml`, re-run `./prepare-values.sh` and -upgrade the installed chart by: +If you made changes to `prepare-values.yaml` or `values_template.yaml`, re-run +`python3 prepare-values.py` and upgrade the installed chart by: ```bash ./prepare-values.sh diff --git a/tls-acme/prepare-values.py b/tls-acme/prepare-values.py new file mode 100755 index 0000000..201a177 --- /dev/null +++ b/tls-acme/prepare-values.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +import yaml +from jinja2 import Environment, FileSystemLoader, StrictUndefined + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description='Render values.yaml from a YAML config file and Jinja template.' + ) + parser.add_argument( + 'config', + nargs='?', + default='prepare-values.yaml', + help='Path to the YAML config file. Default: prepare-values.yaml', + ) + parser.add_argument( + '--template', + default='values_template.yaml', + help='Path to the Jinja values template. Default: values_template.yaml', + ) + parser.add_argument( + '--output', + default='values.yaml', + help='Path to the rendered values file. Default: values.yaml', + ) + return parser + + +def load_yaml(path: Path) -> dict: + with path.open('r', encoding='utf-8') as fh: + data = yaml.safe_load(fh) or {} + if not isinstance(data, dict): + raise ValueError(f'{path} must contain a YAML mapping at the top level.') + return data + + +def derive_fields(config: dict) -> dict: + rendered = dict(config) + + if 'webserver_existing' not in rendered: + use_case = rendered.get('use_case') + if use_case == 'case1': + rendered['webserver_existing'] = True + elif use_case == 'case2': + rendered['webserver_existing'] = False + else: + raise ValueError( + 'Set webserver_existing explicitly, or set use_case to case1 or case2.' + ) + + user_domains = rendered.get('user_domains') + if not isinstance(user_domains, list) or not user_domains: + raise ValueError('user_domains must be a non-empty YAML list.') + if not all(isinstance(domain, str) and domain for domain in user_domains): + raise ValueError('Each entry in user_domains must be a non-empty string.') + + return rendered + + +def render(template_path: Path, context: dict) -> str: + env = Environment( + loader=FileSystemLoader(str(template_path.parent)), + undefined=StrictUndefined, + trim_blocks=True, + lstrip_blocks=True, + ) + template = env.get_template(template_path.name) + return template.render(**context) + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + config_path = Path(args.config).resolve() + template_path = Path(args.template).resolve() + output_path = Path(args.output).resolve() + + if not config_path.exists(): + print( + f'Error: config file not found: {config_path}\n' + f'Hint: copy {config_path.parent / "prepare-values.yaml.example"} ' + f'to {config_path.parent / "prepare-values.yaml"} and edit it.', + file=sys.stderr, + ) + return 1 + + try: + config = derive_fields(load_yaml(config_path)) + rendered = render(template_path, config) + except Exception as exc: + print(f'Error: {exc}', file=sys.stderr) + return 1 + + output_path.write_text(rendered, encoding='utf-8') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tls-acme/prepare-values.sh b/tls-acme/prepare-values.sh deleted file mode 100755 index 3b68c29..0000000 --- a/tls-acme/prepare-values.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -base_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -values_file="${1:-"${base_dir}/values_template.yaml"}" -output_file="${2:-"${base_dir}/values.yaml"}" - -# Update these values before running this script. -# Output goes to values.yaml unless you pass a second argument. -nersc_user_id="" -nersc_user_group="" -service_port="8080" -ingress_name="adminer" # ...svc.spin.nersc.org -domain="" -domain_list=":.." -email="" -cluster="" -use_case="case2" -# case 1: use an existing web-server, specify the same port number used by the webserver's cluster IP. -# case 2: no existing web-server, create one; - -webserver_existing="false" -if [ "${use_case}" = "case1" ]; then - webserver_existing="true" -fi - -tmp_file="$(mktemp)" -sed \ - -e "s|{{nersc_user_id}}|${nersc_user_id}|g" \ - -e "s|{{nersc_user_group}}|${nersc_user_group}|g" \ - -e "s|{{service_port}}|${service_port}|g" \ - -e "s|{{ingress_name}}|${ingress_name}|g" \ - -e "s|{{domain}}|${domain}|g" \ - -e "s|{{domain_list}}|${domain_list}|g" \ - -e "s|{{email}}|${email}|g" \ - -e "s|{{cluster}}|${cluster}|g" \ - -e "s|{{use_case}}|${use_case}|g" \ - -e "s|{{webserver_existing}}|${webserver_existing}|g" \ - "$values_file" > "$tmp_file" -mv "$tmp_file" "$output_file" diff --git a/tls-acme/prepare-values.yaml.example b/tls-acme/prepare-values.yaml.example new file mode 100644 index 0000000..2cf838b --- /dev/null +++ b/tls-acme/prepare-values.yaml.example @@ -0,0 +1,13 @@ +# Copy this file to prepare-values.yaml and update the values for your deployment. +nersc_user_id: 12345 +nersc_user_group: 67890 +service_port: 8080 +ingress_name: my-ingress +email: user@email.com +cluster: production +use_case: case2 +# Optional override. If omitted, prepare-values.py derives it from use_case. +# webserver_existing: false +user_domains: + - app1.example1.com + - app1.example2.com diff --git a/tls-acme/templates/cronjob.yaml b/tls-acme/templates/cronjob.yaml index 89cd4ca..63c153e 100644 --- a/tls-acme/templates/cronjob.yaml +++ b/tls-acme/templates/cronjob.yaml @@ -29,7 +29,7 @@ spec: - name: EMAIL value: {{ .Values.email }} - name: DOMAIN - value: {{ .Values.domain }} + value: {{ join ":" .Values.ingress.userDomains | quote }} - name: KUBECONFIG value: /kube/{{ .Values.kubeconfig.secretName }} - name: CERT_SECRET_NAME diff --git a/tls-acme/templates/ingress.yaml b/tls-acme/templates/ingress.yaml index f5fdd29..39af14a 100644 --- a/tls-acme/templates/ingress.yaml +++ b/tls-acme/templates/ingress.yaml @@ -1,10 +1,17 @@ {{- if .Values.ingress.enabled -}} -{{- $fullName := .Values.ingress.name -}} +{{- $ingressName := .Values.ingress.name -}} {{- $depName := (printf "%s-websrv" (include "spin-acme.fullname" .)) -}} {{- if .Values.webServer.existing -}} {{- $depName = .Values.webServer.deploymentName -}} {{- end}} {{- $svcPort := .Values.service.port -}} +{{- $userDomains := .Values.ingress.userDomains -}} +{{- $clusterDomain := .Values.cluster -}} +{{- if not (contains ".svc.spin.nersc.org" $clusterDomain) -}} + {{- $clusterDomain = printf "%s.svc.spin.nersc.org" $clusterDomain -}} +{{- end -}} +{{- $spinDomain := printf "%s.%s.%s" $ingressName .Release.Namespace $clusterDomain -}} +{{- $allIngressDomains := concat $userDomains (list $spinDomain) -}} {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} @@ -19,7 +26,7 @@ apiVersion: extensions/v1beta1 {{- end }} kind: Ingress metadata: - name: {{ $fullName }} + name: {{ $ingressName }} labels: {{- include "spin-acme.labels" . | nindent 4 }} {{- with .Values.ingress.annotations }} @@ -30,26 +37,22 @@ spec: {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} ingressClassName: {{ .Values.ingress.className }} {{- end }} - {{- if .Values.ingress.tls }} + {{- if $userDomains }} tls: - {{- range .Values.ingress.tls }} - hosts: - {{- range .hosts }} + {{- range $userDomains }} - {{ . | quote }} {{- end }} - - {{ $fullName }}.{{ $.Release.Namespace }}.{{ $.Values.cluster }} - secretName: {{ $.Values.cert.secretName }} - {{- end }} + secretName: {{ .Values.cert.secretName }} {{- end }} rules: - {{- range .Values.ingress.hosts }} - - host: {{ .host | quote }} + {{- range $allIngressDomains }} + - host: {{ . | quote }} http: paths: - {{- range .paths }} - - path: {{ .path }} - {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} - pathType: {{ .pathType }} + - path: {{ $.Values.ingress.path }} + {{- if and $.Values.ingress.pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ $.Values.ingress.pathType }} {{- end }} backend: {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} @@ -61,21 +64,5 @@ spec: serviceName: {{ $depName }} servicePort: {{ $svcPort }} {{- end }} - {{- end }} {{- end }} - - host: {{ $fullName }}.{{ .Release.Namespace }}.{{ .Values.cluster }} - http: - paths: - - path: / - pathType: Prefix - backend: - {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} - service: - name: {{ $depName }} - port: - number: {{ $svcPort }} - {{- else }} - serviceName: {{ $depName }} - servicePort: {{ $svcPort }} - {{- end }} {{- end }} diff --git a/tls-acme/values.yaml.example b/tls-acme/values.yaml.example new file mode 100644 index 0000000..6339c69 --- /dev/null +++ b/tls-acme/values.yaml.example @@ -0,0 +1,69 @@ +# Default values for spin-acme. +# This file is rendered by prepare-values.py using Jinja2. +--- +replicaCount: 1 + +image: + repository: ghcr.io/dingp/acme + pullPolicy: Always + tag: latest + +imagePullSecrets: [] +nameOverride: '' +fullnameOverride: '' + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + fsGroup: 67890 # NERSC GID for the project + +securityContext: + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + runAsNonRoot: true + runAsUser: 12345 # NERSC UID + +service: + type: ClusterIP + port: 8080 # (case 1) port of the existing web server, or 8080 if no pre-existing web server (case 2) + +ingress: + name: test-ingress # name of the ingress, which you already have a DNS CNAME record for. + enabled: true + className: nginx + # User-facing domains only. The internal Spin hostname is derived automatically. + userDomains: + - app.example.org + - www.example.org + path: / + pathType: Prefix + +email: user@example.org # email address to be used for the ACME account + +cluster: development.svc.spin.nersc.org # NERSC cluster name, development.svc.spin.nersc.org, or production.svc.spin.nersc.org + +useCase: case2 # case1 uses existing web server, case2 creates a new one + +webServer: + webrootPath: '/www' + # Set from the config file, or derived by prepare-values.py from use_case. + existing: false # true for case 1; false for case 2 -- use existing deployment and PVC + deploymentName: existing-websrv # case 2, use this deployment + claimName: pvc-existing-webroot # case 2, use this PVC + +kubeconfig: + secretName: kubeconfig + +cert: + secretName: tls-cert + +cronJob: + schedule: '5 12 1 */2 *' # every 2 months + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 \ No newline at end of file diff --git a/tls-acme/values_template.yaml b/tls-acme/values_template.yaml index cf7f588..2c98c0b 100644 --- a/tls-acme/values_template.yaml +++ b/tls-acme/values_template.yaml @@ -1,16 +1,11 @@ # Default values for spin-acme. -# This is a YAML-formatted file. -# Declare variables to be passed into your templates. -# Update the placeholders below before installing the chart. -# You can run ./prepare-values.sh to replace the {{placeholders}} in this file. -# The script writes values.yaml for installation. +# This file is rendered by prepare-values.py using Jinja2. --- replicaCount: 1 image: repository: ghcr.io/dingp/acme pullPolicy: Always - # Overrides the image tag whose default is the chart appVersion. tag: latest imagePullSecrets: [] @@ -21,7 +16,7 @@ podAnnotations: {} podLabels: {} podSecurityContext: - fsGroup: {{nersc_user_group}} # NERSC GID for the project + fsGroup: {{ nersc_user_group }} # NERSC GID for the project securityContext: capabilities: @@ -29,41 +24,34 @@ securityContext: - ALL readOnlyRootFilesystem: false runAsNonRoot: true - runAsUser: {{nersc_user_id}} # NERSC UID + runAsUser: {{ nersc_user_id }} # NERSC UID service: type: ClusterIP - port: {{service_port}} # (case 1) port of the existing web server, or 8080 if no pre-existing web server (case 2) + port: {{ service_port }} # (case 1) port of the existing web server, or 8080 if no pre-existing web server (case 2) ingress: - name: {{ingress_name}} # name of the ingress, which you already have a DNS CNAME record for. + name: {{ ingress_name }} # name of the ingress, which you already have a DNS CNAME record for. enabled: true className: nginx - hosts: - # you can have multiple hosts, all will be added to the same ingress - - host: {{domain}} # DNS CNAME record, or ...svc.spin.nersc.org - paths: - - path: / - pathType: Prefix - tls: - # you can have multiple hosts, all will be added to the same ingress, - # and use the same certificate (with DNS alias in SANs) - - hosts: - - {{domain}} # same as .ingress.hosts.host + # User-facing domains only. The internal Spin hostname is derived automatically. + userDomains: +{% for domain in user_domains %} + - {{ domain }} +{% endfor %} + path: / + pathType: Prefix -# concatenate the list .ingress.hosts.host into ":" separated string -domain: {{domain_list}} +email: {{ email }} # email address to be used for the ACME account -email: {{email}} # email address to be used for the ACME account +cluster: {{ cluster }} # NERSC cluster name, development, production, development.svc.spin.nersc.org, or production.svc.spin.nersc.org -cluster: {{cluster}} # NERSC cluster name, development.svc.spin.nersc.org, or production.svc.spin.nersc.org - -useCase: {{use_case}} # case1 uses existing web server, case2 creates a new one +useCase: {{ use_case }} # case1 uses existing web server, case2 creates a new one webServer: webrootPath: '/www' - # Set by prepare-values.sh based on use_case (case1 or case2). - existing: {{webserver_existing}} # true for case 1; false for case 2 -- use existing deployment and PVC + # Set from the config file, or derived by prepare-values.py from use_case. + existing: {{ webserver_existing | lower }} # true for case 1; false for case 2 -- use existing deployment and PVC deploymentName: existing-websrv # case 2, use this deployment claimName: pvc-existing-webroot # case 2, use this PVC