use super::{join, Builder, Disk, Files, GenError, Plan};
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeploySpec {
pub image: String,
pub port: u16,
pub replicas: u32,
pub max_replicas: u32,
pub dir: String,
}
impl DeploySpec {
pub fn new(image: &str) -> Self {
DeploySpec { image: image.to_string(), port: 7878, replicas: 2, max_replicas: 10, dir: "deploy/k8s".into() }
}
fn command_line(&self) -> String {
let mut words = vec!["rws gen deploy".to_string(), format!("--image {}", self.image)];
if self.port != 7878 {
words.push(format!("--port {}", self.port));
}
if self.replicas != 2 {
words.push(format!("--replicas {}", self.replicas));
}
if self.max_replicas != 10 {
words.push(format!("--max-replicas {}", self.max_replicas));
}
if self.dir != "deploy/k8s" {
words.push(format!("--out {}", self.dir));
}
words.join(" ")
}
}
pub fn k8s_name(name: &str) -> String {
let mut out = String::new();
for c in name.chars() {
let c = c.to_ascii_lowercase();
if c.is_ascii_alphanumeric() {
out.push(c);
} else if !out.ends_with('-') && !out.is_empty() {
out.push('-');
}
}
let out: String = out.trim_matches('-').chars().take(63).collect();
let out = out.trim_end_matches('-').to_string();
if out.is_empty() { "app".to_string() } else { out }
}
fn header(command: &str) -> String {
format!("# Generated by `{command}` (rws {VERSION}). Yours to change.\n")
}
fn deployment(app: &str, s: &DeploySpec, command: &str) -> String {
format!(
r#"{h}apiVersion: apps/v1
kind: Deployment
metadata:
name: {app}
labels:
app.kubernetes.io/name: {app}
spec:
replicas: {replicas}
selector:
matchLabels:
app.kubernetes.io/name: {app}
template:
metadata:
labels:
app.kubernetes.io/name: {app}
spec:
serviceAccountName: {app}
# rws's shutdown: on SIGTERM /readyz fails at once, connections are
# still accepted for shutdown_delay_secs (5), then handlers in flight
# get up to shutdown_timeout_secs (25). The grace period covers both.
terminationGracePeriodSeconds: 35
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: {image}
ports:
- name: http
containerPort: {port}
env:
- name: RWS_CONFIG_IP
value: "0.0.0.0"
- name: RWS_CONFIG_PORT
value: "{port}"
# For leader election and k8s:// references (rust_web_server::k8s).
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
# rust_web_server::health: /startupz holds the others off until
# the startup checks pass; /readyz takes the pod out of the
# Service while a readiness check fails; /livez restarts it.
startupProbe:
httpGet:
path: /startupz
port: http
periodSeconds: 2
failureThreshold: 60
readinessProbe:
httpGet:
path: /readyz
port: http
periodSeconds: 5
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /livez
port: http
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
"#,
h = header(command),
replicas = s.replicas,
image = s.image,
port = s.port,
)
}
fn service(app: &str, command: &str) -> String {
format!(
r#"{h}apiVersion: v1
kind: Service
metadata:
name: {app}
labels:
app.kubernetes.io/name: {app}
spec:
selector:
app.kubernetes.io/name: {app}
ports:
- name: http
port: 80
targetPort: http
"#,
h = header(command),
)
}
fn pdb(app: &str, command: &str) -> String {
format!(
r#"{h}apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {app}
spec:
maxUnavailable: 1
selector:
matchLabels:
app.kubernetes.io/name: {app}
"#,
h = header(command),
)
}
fn hpa(app: &str, s: &DeploySpec, command: &str) -> String {
format!(
r#"{h}apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {app}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {app}
minReplicas: {min}
maxReplicas: {max}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
"#,
h = header(command),
min = s.replicas,
max = s.max_replicas.max(s.replicas),
)
}
fn service_account(app: &str, command: &str) -> String {
format!(
r#"{h}# Add a Role and RoleBinding here when the app reads the Kubernetes API
# (rust_web_server::k8s); an operator prints its own: `<binary> --manifests`.
apiVersion: v1
kind: ServiceAccount
metadata:
name: {app}
automountServiceAccountToken: false
"#,
h = header(command),
)
}
fn kustomization(command: &str) -> String {
format!(
"{}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",
header(command)
)
}
pub fn plan_deploy(dir: impl AsRef<std::path::Path>, spec: &DeploySpec) -> Result<Plan, GenError> {
let dir = dir.as_ref();
if !dir.join("Cargo.toml").is_file() {
return Err(GenError::refused(format!("{} has no Cargo.toml: run rws gen in the project's root (or pass --dir)", dir.display())));
}
plan_with(&Disk(dir.to_path_buf()), spec)
}
pub(crate) fn plan_with(files: &dyn Files, spec: &DeploySpec) -> Result<Plan, GenError> {
if spec.image.trim().is_empty() || spec.image.contains(char::is_whitespace) {
return Err(GenError::usage("--image: the container image, e.g. ghcr.io/acme/notes:1.0.0"));
}
if spec.replicas == 0 || spec.port == 0 {
return Err(GenError::usage("--replicas and --port must be at least 1"));
}
if spec.dir.split('/').any(|p| p == "..") || spec.dir.starts_with('/') {
return Err(GenError::usage("--out: a directory inside the project"));
}
let manifest = files.manifest()?;
let app = k8s_name(&manifest.name);
let command = spec.command_line();
let mut b = Builder { files, changes: Vec::new(), notes: Vec::new() };
let out = spec.dir.trim_end_matches('/');
b.create(&join(out, "deployment.yaml"), deployment(&app, spec, &command));
b.create(&join(out, "service.yaml"), service(&app, &command));
b.create(&join(out, "pdb.yaml"), pdb(&app, &command));
b.create(&join(out, "hpa.yaml"), hpa(&app, spec, &command));
b.create(&join(out, "serviceaccount.yaml"), service_account(&app, &command));
b.create(&join(out, "kustomization.yaml"), kustomization(&command));
b.note(format!("deploy: kubectl apply -k {out}"));
Ok(Plan { command, changes: b.changes, notes: b.notes })
}
#[cfg(test)]
mod tests;