rws_proxy/generator/
deploy.rs1use super::{join, Builder, Disk, Files, GenError, Plan};
9
10const VERSION: &str = env!("CARGO_PKG_VERSION");
11
12#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct DeploySpec {
15 pub image: String,
17 pub port: u16,
19 pub replicas: u32,
21 pub max_replicas: u32,
22 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
49pub 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
241pub 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;