Skip to main content

rws_proxy/generator/
deploy.rs

1//! `rws gen deploy` (18.35.0): Kubernetes manifests for the app in
2//! `deploy/k8s/` — a Deployment wired to rws's probes and graceful
3//! shutdown, a Service, a PodDisruptionBudget, a HorizontalPodAutoscaler
4//! and a ServiceAccount, with a `kustomization.yaml` listing them. Plain
5//! YAML to read, change and `kubectl apply -k deploy/k8s`; like every
6//! generator it never overwrites a file.
7
8use super::{join, Builder, Disk, Files, GenError, Plan};
9
10const VERSION: &str = env!("CARGO_PKG_VERSION");
11
12/// What `rws gen deploy` writes for.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct DeploySpec {
15    /// The container image (`ghcr.io/acme/notes:1.4.0`).
16    pub image: String,
17    /// The container port rws listens on (default 7878).
18    pub port: u16,
19    /// Replicas, and the autoscaler's maximum (default 2 and 10).
20    pub replicas: u32,
21    pub max_replicas: u32,
22    /// Where the files go (default `deploy/k8s`).
23    pub dir: String,
24}
25
26impl DeploySpec {
27    pub fn new(image: &str) -> Self {
28        DeploySpec { image: image.to_string(), port: 7878, replicas: 2, max_replicas: 10, dir: "deploy/k8s".into() }
29    }
30
31    fn command_line(&self) -> String {
32        let mut words = vec!["rws gen deploy".to_string(), format!("--image {}", self.image)];
33        if self.port != 7878 {
34            words.push(format!("--port {}", self.port));
35        }
36        if self.replicas != 2 {
37            words.push(format!("--replicas {}", self.replicas));
38        }
39        if self.max_replicas != 10 {
40            words.push(format!("--max-replicas {}", self.max_replicas));
41        }
42        if self.dir != "deploy/k8s" {
43            words.push(format!("--out {}", self.dir));
44        }
45        words.join(" ")
46    }
47}
48
49/// A DNS-1123 label from the app's name: lower case, letters, digits, `-`.
50pub fn k8s_name(name: &str) -> String {
51    let mut out = String::new();
52    for c in name.chars() {
53        let c = c.to_ascii_lowercase();
54        if c.is_ascii_alphanumeric() {
55            out.push(c);
56        } else if !out.ends_with('-') && !out.is_empty() {
57            out.push('-');
58        }
59    }
60    let out: String = out.trim_matches('-').chars().take(63).collect();
61    let out = out.trim_end_matches('-').to_string();
62    if out.is_empty() { "app".to_string() } else { out }
63}
64
65fn header(command: &str) -> String {
66    format!("# Generated by `{command}` (rws {VERSION}). Yours to change.\n")
67}
68
69fn deployment(app: &str, s: &DeploySpec, command: &str) -> String {
70    format!(
71        r#"{h}apiVersion: apps/v1
72kind: Deployment
73metadata:
74  name: {app}
75  labels:
76    app.kubernetes.io/name: {app}
77spec:
78  replicas: {replicas}
79  selector:
80    matchLabels:
81      app.kubernetes.io/name: {app}
82  template:
83    metadata:
84      labels:
85        app.kubernetes.io/name: {app}
86    spec:
87      serviceAccountName: {app}
88      # rws's shutdown: on SIGTERM /readyz fails at once, connections are
89      # still accepted for shutdown_delay_secs (5), then handlers in flight
90      # get up to shutdown_timeout_secs (25). The grace period covers both.
91      terminationGracePeriodSeconds: 35
92      securityContext:
93        runAsNonRoot: true
94        seccompProfile:
95          type: RuntimeDefault
96      containers:
97        - name: app
98          image: {image}
99          ports:
100            - name: http
101              containerPort: {port}
102          env:
103            - name: RWS_CONFIG_IP
104              value: "0.0.0.0"
105            - name: RWS_CONFIG_PORT
106              value: "{port}"
107            # For leader election and k8s:// references (rust_web_server::k8s).
108            - name: POD_NAME
109              valueFrom:
110                fieldRef:
111                  fieldPath: metadata.name
112            - name: POD_NAMESPACE
113              valueFrom:
114                fieldRef:
115                  fieldPath: metadata.namespace
116          # rust_web_server::health: /startupz holds the others off until
117          # the startup checks pass; /readyz takes the pod out of the
118          # Service while a readiness check fails; /livez restarts it.
119          startupProbe:
120            httpGet:
121              path: /startupz
122              port: http
123            periodSeconds: 2
124            failureThreshold: 60
125          readinessProbe:
126            httpGet:
127              path: /readyz
128              port: http
129            periodSeconds: 5
130            timeoutSeconds: 3
131          livenessProbe:
132            httpGet:
133              path: /livez
134              port: http
135            periodSeconds: 10
136            timeoutSeconds: 3
137            failureThreshold: 3
138          resources:
139            requests:
140              cpu: 100m
141              memory: 64Mi
142            limits:
143              memory: 256Mi
144          securityContext:
145            allowPrivilegeEscalation: false
146            readOnlyRootFilesystem: true
147            capabilities:
148              drop: [ALL]
149"#,
150        h = header(command),
151        replicas = s.replicas,
152        image = s.image,
153        port = s.port,
154    )
155}
156
157fn service(app: &str, command: &str) -> String {
158    format!(
159        r#"{h}apiVersion: v1
160kind: Service
161metadata:
162  name: {app}
163  labels:
164    app.kubernetes.io/name: {app}
165spec:
166  selector:
167    app.kubernetes.io/name: {app}
168  ports:
169    - name: http
170      port: 80
171      targetPort: http
172"#,
173        h = header(command),
174    )
175}
176
177fn pdb(app: &str, command: &str) -> String {
178    format!(
179        r#"{h}apiVersion: policy/v1
180kind: PodDisruptionBudget
181metadata:
182  name: {app}
183spec:
184  maxUnavailable: 1
185  selector:
186    matchLabels:
187      app.kubernetes.io/name: {app}
188"#,
189        h = header(command),
190    )
191}
192
193fn hpa(app: &str, s: &DeploySpec, command: &str) -> String {
194    format!(
195        r#"{h}apiVersion: autoscaling/v2
196kind: HorizontalPodAutoscaler
197metadata:
198  name: {app}
199spec:
200  scaleTargetRef:
201    apiVersion: apps/v1
202    kind: Deployment
203    name: {app}
204  minReplicas: {min}
205  maxReplicas: {max}
206  metrics:
207    - type: Resource
208      resource:
209        name: cpu
210        target:
211          type: Utilization
212          averageUtilization: 70
213"#,
214        h = header(command),
215        min = s.replicas,
216        max = s.max_replicas.max(s.replicas),
217    )
218}
219
220fn service_account(app: &str, command: &str) -> String {
221    format!(
222        r#"{h}# Add a Role and RoleBinding here when the app reads the Kubernetes API
223# (rust_web_server::k8s); an operator prints its own: `<binary> --manifests`.
224apiVersion: v1
225kind: ServiceAccount
226metadata:
227  name: {app}
228automountServiceAccountToken: false
229"#,
230        h = header(command),
231    )
232}
233
234fn kustomization(command: &str) -> String {
235    format!(
236        "{}apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n  - serviceaccount.yaml\n  - deployment.yaml\n  - service.yaml\n  - pdb.yaml\n  - hpa.yaml\n",
237        header(command)
238    )
239}
240
241/// What `rws gen deploy` would write in the project at `dir`.
242pub fn plan_deploy(dir: impl AsRef<std::path::Path>, spec: &DeploySpec) -> Result<Plan, GenError> {
243    let dir = dir.as_ref();
244    if !dir.join("Cargo.toml").is_file() {
245        return Err(GenError::refused(format!("{} has no Cargo.toml: run rws gen in the project's root (or pass --dir)", dir.display())));
246    }
247    plan_with(&Disk(dir.to_path_buf()), spec)
248}
249
250pub(crate) fn plan_with(files: &dyn Files, spec: &DeploySpec) -> Result<Plan, GenError> {
251    if spec.image.trim().is_empty() || spec.image.contains(char::is_whitespace) {
252        return Err(GenError::usage("--image: the container image, e.g. ghcr.io/acme/notes:1.0.0"));
253    }
254    if spec.replicas == 0 || spec.port == 0 {
255        return Err(GenError::usage("--replicas and --port must be at least 1"));
256    }
257    if spec.dir.split('/').any(|p| p == "..") || spec.dir.starts_with('/') {
258        return Err(GenError::usage("--out: a directory inside the project"));
259    }
260    let manifest = files.manifest()?;
261    let app = k8s_name(&manifest.name);
262    let command = spec.command_line();
263    let mut b = Builder { files, changes: Vec::new(), notes: Vec::new() };
264    let out = spec.dir.trim_end_matches('/');
265    b.create(&join(out, "deployment.yaml"), deployment(&app, spec, &command));
266    b.create(&join(out, "service.yaml"), service(&app, &command));
267    b.create(&join(out, "pdb.yaml"), pdb(&app, &command));
268    b.create(&join(out, "hpa.yaml"), hpa(&app, spec, &command));
269    b.create(&join(out, "serviceaccount.yaml"), service_account(&app, &command));
270    b.create(&join(out, "kustomization.yaml"), kustomization(&command));
271    b.note(format!("deploy: kubectl apply -k {out}"));
272    Ok(Plan { command, changes: b.changes, notes: b.notes })
273}
274
275#[cfg(test)]
276mod tests;