diff --git a/Cargo.lock b/Cargo.lock index 4c2d7e5a..fa8e4e61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2953,6 +2953,7 @@ dependencies = [ "itertools 0.14.0", "k8s-openapi", "lazy_static", + "oci-spec", "pem", "policy-evaluator", "predicates", diff --git a/Cargo.toml b/Cargo.toml index 30688b27..02a46e29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ k8s-openapi = { version = "0.24.0", default-features = false, features = [ "v1_30", ] } lazy_static = "1.4.0" +oci-spec = "0.7.1" pem = "3" policy-evaluator = { git = "https://github.com/kubewarden/policy-evaluator", tag = "v0.21.0" } prettytable-rs = "^0.10" diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 00000000..a665c6d7 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +name: volumes-psp +description: Pod Security Policy that controls usage of volumes +type: application +home: https://github.com/kubewarden/volumes-psp-policy +keywords: null +version: '123' +appVersion: '123' +annotations: + io.artifacthub.displayName: Volumes PSP + io.artifacthub.keywords: psp, pod, volumes + io.artifacthub.resources: Pod + io.kubewarden.policy.author: Kubewarden developers + io.kubewarden.policy.category: PSP + io.kubewarden.policy.description: Pod Security Policy that controls usage of volumes + io.kubewarden.policy.license: Apache-2.0 + io.kubewarden.policy.ociUrl: ghcr.io/kubewarden/policies/volumes-psp + io.kubewarden.policy.severity: medium + io.kubewarden.policy.source: https://github.com/kubewarden/volumes-psp-policy + io.kubewarden.policy.title: volumes-psp + io.kubewarden.policy.url: https://github.com/kubewarden/volumes-psp-policy diff --git a/chart/questions.yaml b/chart/questions.yaml new file mode 100644 index 00000000..5696fac5 --- /dev/null +++ b/chart/questions.yaml @@ -0,0 +1,23 @@ +questions: +- default: null + description: >- + Replacement for the Kubernetes Pod Security Policy that controls the usage + of volumes in pods. The policy takes the list of the allowed volume types + using the allowedTypes setting. The special value * can be used to allow all + kind of volumes. + group: Settings + required: false + hide_input: true + type: string + variable: description +- default: [] + description: '' + tooltip: >- + A list of the allowed volume types. Note: no other value can be specified + together with *. For example, allowedTypes: ['*', 'configMap'] is not a + valid configuration setting. + group: Settings + label: Allowed types + required: false + type: array[ + variable: allowedTypes diff --git a/chart/templates/policy.yaml b/chart/templates/policy.yaml new file mode 100644 index 00000000..4856f77e --- /dev/null +++ b/chart/templates/policy.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: policies.kubewarden.io/v1 +{{- if eq .Values.clusterScoped true }} +kind: ClusterAdmissionPolicy +{{- else }} +kind: AdmissionPolicy +{{- end }} +metadata: + name: {{ .Release.name }} + {{- if eq .Values.clusterScoped false }} + namespace: {{ .Release.namespace }} + {{- end }} +spec: + module: '{{ .Values.spec.module.repository }}:{{ .Values.spec.module.tag }}' + mode: {{ .Values.spec.mode }} + mutating: {{ .Values.spec.mutating }} + rules: + {{- toYaml .Values.spec.rules | nindent 4 }} + settings: + {{- toYaml .Values.spec.settings | replace "|\n" "" | nindent 2 }} diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 00000000..85f41512 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,21 @@ +global: + cattle: + systemDefaultRegistry: ghcr.io +clusterScoped: true +spec: + module: + repository: kubewarden/policies/volumes-psp + tag: '123' + mode: kubewarden-wapc + mutating: false + contextAwareResources: [] + rules: + - apiGroups: + - '' + apiVersions: + - v1 + resources: + - pods + operations: + - CREATE + settings: {} diff --git a/src/cli.rs b/src/cli.rs index d6fca92e..077d825d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,8 @@ +use std::path::PathBuf; + use clap::{ builder::PossibleValuesParser, crate_authors, crate_description, crate_name, crate_version, - Arg, ArgAction, ArgGroup, Command, + value_parser, Arg, ArgAction, ArgGroup, Command, }; use lazy_static::lazy_static; @@ -525,6 +527,39 @@ fn subcommand_scaffold() -> Command { ]; admission_request_args.sort_by(|a, b| a.get_id().cmp(b.get_id())); + let chart_args = vec![ + Arg::new("version") + .long("version") + .short('t') + .required(true) + .value_name("STRING") + .help("The version of the policy"), + Arg::new("no-settings") + .long("no-settings") + .action(ArgAction::SetTrue) + .help("Disable settings for this policy"), + Arg::new("metadata-path") + .long("metadata-path") + .short('m') + .value_name("PATH") + .value_parser(value_parser!(PathBuf)) + .default_value("metadata.yml") + .help("File containing the metadata of the policy"), + Arg::new("questions-path") + .long("questions-path") + .short('q') + .value_name("PATH") + .value_parser(value_parser!(PathBuf)) + .help("File containing the questions-ui content of the policy"), + Arg::new("output-path") + .long("output-path") + .short('o') + .value_name("PATH") + .value_parser(value_parser!(PathBuf)) + .default_value("chart") + .help("Path where the Helm chart will be stored"), + ]; + let mut subcommands = vec![ Command::new("verification-config") .about("Output a default Sigstore verification configuration file"), @@ -540,6 +575,9 @@ fn subcommand_scaffold() -> Command { Command::new("admission-request") .about("Scaffold an AdmissionRequest object") .args(admission_request_args), + Command::new("chart") + .about("Output a Helm chart for a Kubewarden policy") + .args(chart_args) ]; subcommands.sort_by(|a, b| a.get_name().cmp(b.get_name())); diff --git a/src/main.rs b/src/main.rs index ef8b5aaa..e8fa4530 100644 --- a/src/main.rs +++ b/src/main.rs @@ -312,79 +312,113 @@ async fn main() -> Result<()> { Ok(()) } Some("scaffold") => { - if let Some(matches) = matches.subcommand_matches("scaffold") { - if let Some(_matches) = matches.subcommand_matches("verification-config") { - println!("{}", scaffold::verification_config()?); - } - } - if let Some(matches) = matches.subcommand_matches("scaffold") { - if let Some(artifacthub_matches) = matches.subcommand_matches("artifacthub") { - let metadata_file = artifacthub_matches - .get_one::("metadata-path") - .map(|output| PathBuf::from_str(output).unwrap()) - .unwrap(); - let version = artifacthub_matches.get_one::("version").unwrap(); - let gh_release_tag = artifacthub_matches - .get_one::("gh-release-tag") - .cloned(); - let questions_file = artifacthub_matches - .get_one::("questions-path") - .map(|output| PathBuf::from_str(output).unwrap()); - let content = scaffold::artifacthub( - metadata_file, - version, - gh_release_tag.as_deref(), - questions_file, - )?; - if let Some(output) = artifacthub_matches.get_one::("output") { - let output_path = PathBuf::from_str(output)?; - fs::write(output_path, content)?; - } else { - println!("{}", content); + if let Some(scaffold_matches) = matches.subcommand_matches("scaffold") { + match scaffold_matches.subcommand() { + Some(("verification-config", _)) => { + println!("{}", scaffold::verification_config()?); + } + Some(("artifacthub", artifacthub_matches)) => { + let metadata_file = artifacthub_matches + .get_one::("metadata-path") + .map(|output| PathBuf::from_str(output).unwrap()) + .unwrap(); + let version = artifacthub_matches.get_one::("version").unwrap(); + let gh_release_tag = artifacthub_matches + .get_one::("gh-release-tag") + .cloned(); + let questions_file = artifacthub_matches + .get_one::("questions-path") + .map(|output| PathBuf::from_str(output).unwrap()); + let content = scaffold::artifacthub( + metadata_file, + version, + gh_release_tag.as_deref(), + questions_file, + )?; + if let Some(output) = artifacthub_matches.get_one::("output") { + let output_path = PathBuf::from_str(output)?; + fs::write(output_path, content)?; + } else { + println!("{}", content); + } + } + Some(("manifest", manifest_matches)) => { + scaffold_manifest_command(manifest_matches).await?; + } + Some(("vap", vap_matches)) => { + let cel_policy_uri = vap_matches.get_one::("cel-policy").unwrap(); + let vap_file: PathBuf = + vap_matches.get_one::("policy").unwrap().into(); + let vap_binding_file: PathBuf = + vap_matches.get_one::("binding").unwrap().into(); + + scaffold::vap( + cel_policy_uri.as_str(), + vap_file.as_path(), + vap_binding_file.as_path(), + )?; + } + Some(("admission-request", admission_request_matches)) => { + let operation: scaffold::AdmissionRequestOperation = + admission_request_matches + .get_one::("operation") + .unwrap() + .parse::() + .map_err(|e| anyhow!("Error parsing operation: {}", e))?; + let object_path: Option = + if admission_request_matches.contains_id("object") { + Some( + admission_request_matches + .get_one::("object") + .unwrap() + .into(), + ) + } else { + None + }; + let old_object_path: Option = + if admission_request_matches.contains_id("old-object") { + Some( + admission_request_matches + .get_one::("old-object") + .unwrap() + .into(), + ) + } else { + None + }; + + scaffold::admission_request(operation, object_path, old_object_path) + .await?; } + Some(("chart", chart_matches)) => { + let version = chart_matches + .get_one::("version") + .expect("version is required"); + let metadata_path = chart_matches + .get_one::("metadata-path") + .expect("metadata path is required"); + let has_settings = !chart_matches + .get_one::("no-settings") + .expect("no-settings is required") + .to_owned(); + let questions_path = chart_matches.get_one::("questions-path"); + + let output_path = chart_matches + .get_one::("output-path") + .expect("output path is required"); + + scaffold::chart( + version, + has_settings, + metadata_path, + questions_path, + output_path, + )?; + } + _ => {} } } - if let Some(matches) = matches.subcommand_matches("scaffold") { - if let Some(matches) = matches.subcommand_matches("manifest") { - scaffold_manifest_command(matches).await?; - }; - } - if let Some(matches) = matches.subcommand_matches("scaffold") { - if let Some(matches) = matches.subcommand_matches("vap") { - let cel_policy_uri = matches.get_one::("cel-policy").unwrap(); - let vap_file: PathBuf = matches.get_one::("policy").unwrap().into(); - let vap_binding_file: PathBuf = - matches.get_one::("binding").unwrap().into(); - - scaffold::vap( - cel_policy_uri.as_str(), - vap_file.as_path(), - vap_binding_file.as_path(), - )?; - }; - } - if let Some(matches) = matches.subcommand_matches("scaffold") { - if let Some(matches) = matches.subcommand_matches("admission-request") { - let operation: scaffold::AdmissionRequestOperation = matches - .get_one::("operation") - .unwrap() - .parse::() - .map_err(|e| anyhow!("Error parsing operation: {}", e))?; - let object_path: Option = if matches.contains_id("object") { - Some(matches.get_one::("object").unwrap().into()) - } else { - None - }; - let old_object_path: Option = if matches.contains_id("old-object") { - Some(matches.get_one::("old-object").unwrap().into()) - } else { - None - }; - - scaffold::admission_request(operation, object_path, old_object_path).await?; - }; - } - Ok(()) } Some("completions") => { diff --git a/src/scaffold.rs b/src/scaffold.rs index 2de6bcbe..2f61c2ea 100644 --- a/src/scaffold.rs +++ b/src/scaffold.rs @@ -15,3 +15,6 @@ pub(crate) use artifacthub::artifacthub; mod admission_request; pub(crate) use admission_request::Operation as AdmissionRequestOperation; pub(crate) use admission_request::{admission_request, DEFAULT_KWCTL_CACHE}; + +mod chart; +pub(crate) use chart::chart; diff --git a/src/scaffold/chart.rs b/src/scaffold/chart.rs new file mode 100644 index 00000000..5f0d5945 --- /dev/null +++ b/src/scaffold/chart.rs @@ -0,0 +1,180 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, + str::FromStr, +}; + +use anyhow::{anyhow, Result}; +use oci_spec::distribution::Reference; +use policy_evaluator::policy_metadata::{ContextAwareResource, Metadata, Rule}; +use serde::Serialize; +use tracing::warn; + +/// Represents the Chart.yaml file of the chart +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Chart { + api_version: String, + name: String, + description: String, + #[serde(rename = "type")] + chart_type: String, + home: String, + keywords: Option>, + version: String, + app_version: String, + annotations: Option>, +} + +/// Represents the values.yml file of the chart +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Values { + global: Global, + cluster_scoped: bool, + spec: Spec, +} + +#[derive(Serialize)] +struct Global { + cattle: Cattle, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Cattle { + system_default_registry: String, +} + +/// Represents the spec.module field in the values.yml file +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Module { + repository: String, + tag: String, +} + +/// Represents the spec field in the values.yml file +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Spec { + module: Module, + mode: String, + mutating: bool, + context_aware_resources: BTreeSet, + rules: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + settings: Option, +} + +pub(crate) fn chart( + version: &str, + has_settings: bool, + metadata_path: impl AsRef, + questions_path: Option>, + output_path: impl AsRef, +) -> Result<()> { + let metadata_yaml = std::fs::read_to_string(metadata_path) + .map_err(|e| anyhow!("Failed to read metadata file: {}", e))?; + let metadata: Metadata = serde_yaml::from_str(&metadata_yaml) + .map_err(|e| anyhow!("Failed to parse metadata file: {}", e))?; + + let annotations = metadata + .annotations + .as_ref() + .cloned() + .ok_or_else(|| anyhow!("Missing metadata annotations"))?; + + let oci_url = annotations + .get("io.kubewarden.policy.ociUrl") + .ok_or_else(|| anyhow!("Missing repository annotation"))?; + let image_ref = Reference::from_str(oci_url).map_err(|e| anyhow!("Invalid OCI URL: {}", e))?; + let repository = image_ref.repository().to_owned(); + let registry = image_ref.registry().to_owned(); + + let name = annotations + .get("io.kubewarden.policy.title") + .ok_or_else(|| anyhow!("Missing title annotation"))?; + let description = annotations + .get("io.kubewarden.policy.description") + .ok_or_else(|| anyhow!("Missing description annotation"))?; + let home = annotations + .get("io.kubewarden.policy.url") + .ok_or_else(|| anyhow!("Missing url annotation."))?; + let keywords = annotations + .get("io.kubewarden.policy.keywords") + .map(|keywords| keywords.split(',').map(|s| s.trim().to_owned()).collect()); + + std::fs::create_dir_all(&output_path) + .map_err(|e| anyhow!("Failed to create directory: {}", e))?; + + // Chart.yaml + let chart = Chart { + api_version: "v2".to_owned(), + name: name.to_owned(), + description: description.to_owned(), + chart_type: "application".to_owned(), + home: home.to_owned(), + keywords, + version: version.to_owned(), + app_version: version.to_owned(), + annotations: metadata.annotations, + }; + let chart_yaml = + serde_yaml::to_string(&chart).map_err(|e| anyhow!("Failed to serialize chart: {}", e))?; + let chart_yaml_output_path = output_path.as_ref().join("Chart.yaml"); + std::fs::write(&chart_yaml_output_path, chart_yaml.as_bytes()) + .map_err(|e| anyhow!("Failed to write chart file: {}", e))?; + + // values.yaml + let settings = if has_settings { + Some(serde_yaml::Mapping::new()) + } else { + None + }; + + let values = Values { + global: Global { + cattle: Cattle { + system_default_registry: registry.to_owned(), + }, + }, + cluster_scoped: true, + spec: Spec { + module: Module { + repository: repository.to_owned(), + tag: version.to_owned(), + }, + mode: metadata.execution_mode.to_string(), + mutating: metadata.mutating, + context_aware_resources: metadata.context_aware_resources.clone(), + rules: metadata.rules.clone(), + settings, + }, + }; + let values_yaml = + serde_yaml::to_string(&values).map_err(|e| anyhow!("Failed to serialize values: {}", e))?; + let values_yaml_output_path = output_path.as_ref().join("values.yaml"); + std::fs::write(&values_yaml_output_path, values_yaml.as_bytes()) + .map_err(|e| anyhow!("Failed to write values file: {}", e))?; + + // questions.yaml + if let Some(path) = questions_path { + if !has_settings { + warn!("Ignoring questions file because the policy does not have settings"); + } else { + let questions_yaml_output_path = output_path.as_ref().join("questions.yaml"); + std::fs::copy(path, &questions_yaml_output_path) + .map_err(|e| anyhow!("Failed to copy questions file: {}", e))?; + } + } + + // templates/policy.yaml + let policy_yaml_bytes = include_bytes!("templates/policy.yaml"); + let policy_yaml_output_path = output_path.as_ref().join("templates").join("policy.yaml"); + std::fs::create_dir_all(policy_yaml_output_path.parent().unwrap()) + .map_err(|e| anyhow!("Failed to create templates directory: {}", e))?; + std::fs::write(policy_yaml_output_path, policy_yaml_bytes)?; + + Ok(()) +} diff --git a/src/scaffold/templates/policy.yaml b/src/scaffold/templates/policy.yaml new file mode 100644 index 00000000..4856f77e --- /dev/null +++ b/src/scaffold/templates/policy.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: policies.kubewarden.io/v1 +{{- if eq .Values.clusterScoped true }} +kind: ClusterAdmissionPolicy +{{- else }} +kind: AdmissionPolicy +{{- end }} +metadata: + name: {{ .Release.name }} + {{- if eq .Values.clusterScoped false }} + namespace: {{ .Release.namespace }} + {{- end }} +spec: + module: '{{ .Values.spec.module.repository }}:{{ .Values.spec.module.tag }}' + mode: {{ .Values.spec.mode }} + mutating: {{ .Values.spec.mutating }} + rules: + {{- toYaml .Values.spec.rules | nindent 4 }} + settings: + {{- toYaml .Values.spec.settings | replace "|\n" "" | nindent 2 }}