rws-proxy 18.35.0

The proxy and operations layer of rust-web-server: reverse proxy, config-driven routes, load balancing, L4 and WebSocket proxies, the admin API, cluster sync, fleet pull, WAF, LLM and A2A gateways, FastCGI, traffic insights. (internal: use the rust-web-server crate).
Documentation
//! `rws gen deploy` (18.35.0): Kubernetes manifests for the app in
//! `deploy/k8s/` — a Deployment wired to rws's probes and graceful
//! shutdown, a Service, a PodDisruptionBudget, a HorizontalPodAutoscaler
//! and a ServiceAccount, with a `kustomization.yaml` listing them. Plain
//! YAML to read, change and `kubectl apply -k deploy/k8s`; like every
//! generator it never overwrites a file.

use super::{join, Builder, Disk, Files, GenError, Plan};

const VERSION: &str = env!("CARGO_PKG_VERSION");

/// What `rws gen deploy` writes for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeploySpec {
    /// The container image (`ghcr.io/acme/notes:1.4.0`).
    pub image: String,
    /// The container port rws listens on (default 7878).
    pub port: u16,
    /// Replicas, and the autoscaler's maximum (default 2 and 10).
    pub replicas: u32,
    pub max_replicas: u32,
    /// Where the files go (default `deploy/k8s`).
    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(" ")
    }
}

/// A DNS-1123 label from the app's name: lower case, letters, digits, `-`.
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)
    )
}

/// What `rws gen deploy` would write in the project at `dir`.
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;