Refactor the reconciler and add Woodpecker CI

Now controller looks a bit better and its parts can be tested with
integration tests, that are going to be implemented later.
This commit is contained in:
Nikolai Rodionov 2023-12-26 11:15:38 +01:00
parent fc2c20901f
commit 439e735203
Signed by: allanger
GPG Key ID: 0AA46A90E25592AD
9 changed files with 322 additions and 180 deletions

47
.woodpecker/build.yaml Normal file
View File

@ -0,0 +1,47 @@
# Build a container image
when:
event:
- push
services:
docker:
image: docker:dind
commands:
- echo "1" > /proc/sys/net/ipv4/ip_forward
- dockerd -H tcp://0.0.0.0:2375 --tls=false
privileged: true
ports:
- 2375
- 16443
backend_options:
kubernetes:
resources:
requests:
memory: 500Mi
cpu: 200m
limits:
memory: 1000Mi
cpu: 1000m
steps:
build:
image: git.badhouseplants.net/badhouseplants/badhouseplants-builder:555262114ea81f6f286010474527f419b56d33a3
name: Build shoebill operator image
privileged: true
environment:
- PACKAGE_NAME=allanger/shoebill-operator
commands:
- |
if [[ "${CI_COMMIT_TAG}" ]]; then
export CUSTOM_TAG="${CI_COMMIT_TAG}";
fi
- build-container
secrets:
- gitea_token
backend_options:
kubernetes:
resources:
requests:
memory: 500Mi
cpu: 200m
limits:
memory: 1000Mi
cpu: 1000m

View File

@ -4,4 +4,8 @@ use clap::{Args, Command, Parser, Subcommand};
pub(crate) struct ManifestsArgs { pub(crate) struct ManifestsArgs {
#[arg(long, short, default_value = "default")] #[arg(long, short, default_value = "default")]
pub(crate) namespace: String, pub(crate) namespace: String,
#[arg(long, short, default_value = "latest")]
pub(crate) tag: String,
#[arg(long, short, default_value = "shoebill")]
pub(crate) image: String,
} }

View File

@ -1,8 +1,11 @@
use crate::api::v1alpha1::configsets_api::ConfigSet; use crate::api::v1alpha1::configsets_api::{
ConfigSet, Input, InputWithName, TargetWithName, Templates,
};
use futures::StreamExt; use futures::StreamExt;
use handlebars::Handlebars; use handlebars::Handlebars;
use k8s_openapi::api::core::v1::{ConfigMap, Secret}; use k8s_openapi::api::core::v1::{ConfigMap, Secret};
use k8s_openapi::ByteString; use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference;
use k8s_openapi::{ByteString, NamespaceResourceScope};
use kube::api::{ListParams, PostParams}; use kube::api::{ListParams, PostParams};
use kube::core::{Object, ObjectMeta}; use kube::core::{Object, ObjectMeta};
use kube::error::ErrorResponse; use kube::error::ErrorResponse;
@ -10,6 +13,8 @@ use kube::runtime::controller::Action;
use kube::runtime::watcher::Config; use kube::runtime::watcher::Config;
use kube::runtime::Controller; use kube::runtime::Controller;
use kube::{Api, Client, CustomResource}; use kube::{Api, Client, CustomResource};
use kube_client::core::DynamicObject;
use kube_client::Resource;
use log::*; use log::*;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -18,6 +23,7 @@ use std::str::{from_utf8, Utf8Error};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum Error { pub enum Error {
#[error("SerializationError: {0}")] #[error("SerializationError: {0}")]
@ -31,8 +37,8 @@ pub enum Error {
// so boxing this error to break cycles // so boxing this error to break cycles
FinalizerError(#[source] Box<kube::runtime::finalizer::Error<Error>>), FinalizerError(#[source] Box<kube::runtime::finalizer::Error<Error>>),
#[error("IllegalDocument")] #[error("IllegalConfigSet")]
IllegalDocument, IllegalConfigSet,
} }
pub type Result<T, E = Error> = std::result::Result<T, E>; pub type Result<T, E = Error> = std::result::Result<T, E>;
impl Error { impl Error {
@ -85,65 +91,84 @@ fn error_policy(doc: Arc<ConfigSet>, error: &Error, ctx: Arc<Context>) -> Action
Action::requeue(Duration::from_secs(5 * 60)) Action::requeue(Duration::from_secs(5 * 60))
} }
impl ConfigSet { fn get_secret_api(client: Client, namespace: String) -> Api<Secret> {
// Reconcile (for non-finalizer related changes) Api::namespaced(client, &namespace)
async fn reconcile(&self, ctx: Arc<Context>) -> Result<Action> { }
/*
* First we need to get inputs and write them to the map fn get_configmap_api(client: Client, namespace: String) -> Api<ConfigMap> {
* Then use them to build new values with templates Api::namespaced(client, &namespace)
* And then write those values to targets }
*/
let mut inputs: HashMap<String, String> = HashMap::new(); async fn gather_inputs(
for input in self.spec.inputs.clone() { client: Client,
info!("populating data from input {}", input.name); namespace: String,
match input.from.kind { inputs: Vec<InputWithName>,
) -> Result<HashMap<String, String>> {
let mut result: HashMap<String, String> = HashMap::new();
for i in inputs {
info!("populating data from input {}", i.name);
match i.from.kind {
crate::api::v1alpha1::configsets_api::Kinds::Secret => { crate::api::v1alpha1::configsets_api::Kinds::Secret => {
let secrets: Api<Secret> = Api::namespaced( let secret: String = match get_secret_api(client.clone(), namespace.clone())
ctx.client.clone(), .get(&i.from.name)
self.metadata.namespace.clone().unwrap().as_str(), .await
); {
let secret: String = match secrets.get(&input.from.name).await { Ok(s) => {
Ok(s) => from_utf8(&s.data.clone().unwrap()[input.from.key.as_str()].0) let data = s.data.clone().unwrap();
.unwrap() let value = match data.get(i.from.key.as_str()) {
.to_string(), Some(data) => match from_utf8(&data.0) {
Ok(data) => data,
Err(_) => return Err(Error::IllegalConfigSet),
},
None => return Err(Error::IllegalConfigSet),
};
value.to_string()
}
Err(err) => { Err(err) => {
error!("{err}"); error!("{err}");
return Err(Error::KubeError(err)); return Err(Error::KubeError(err));
} }
}; };
inputs.insert(input.from.key, secret); result.insert(i.from.key, secret);
} }
crate::api::v1alpha1::configsets_api::Kinds::ConfigMap => { crate::api::v1alpha1::configsets_api::Kinds::ConfigMap => {
let configmaps: Api<ConfigMap> = Api::namespaced( let configmap: String = match get_configmap_api(client.clone(), namespace.clone())
ctx.client.clone(), .get(&i.from.name)
self.metadata.namespace.clone().unwrap().as_str(), .await
); {
let configmap: String = match configmaps.get(&input.from.name).await {
Ok(cm) => { Ok(cm) => {
let data = &cm.data.unwrap()[input.from.key.as_str()]; let data = cm.data.unwrap();
data.to_string() let value = match data.get(i.from.key.as_str()) {
Some(data) => data,
None => return Err(Error::IllegalConfigSet),
};
value.to_string()
} }
Err(err) => { Err(err) => {
error!("{err}"); error!("{err}");
return Err(Error::KubeError(err)); return Err(Error::KubeError(err));
} }
}; };
inputs.insert(input.name, configmap); result.insert(i.name, configmap);
} }
} }
} }
Ok(result)
}
async fn gather_targets(
client: Client,
namespace: String,
targets: Vec<TargetWithName>,
owner_reference: Vec<OwnerReference>,
) -> Result<(HashMap<String, Secret>, HashMap<String, ConfigMap>)> {
let mut target_secrets: HashMap<String, Secret> = HashMap::new(); let mut target_secrets: HashMap<String, Secret> = HashMap::new();
let mut target_configmaps: HashMap<String, ConfigMap> = HashMap::new(); let mut target_configmaps: HashMap<String, ConfigMap> = HashMap::new();
for target in targets {
for target in self.spec.targets.clone() {
match target.target.kind { match target.target.kind {
crate::api::v1alpha1::configsets_api::Kinds::Secret => { crate::api::v1alpha1::configsets_api::Kinds::Secret => {
let secrets: Api<Secret> = Api::namespaced( let api = get_secret_api(client.clone(), namespace.clone());
ctx.client.clone(), match api.get_opt(&target.target.name).await {
self.metadata.namespace.clone().unwrap().as_str(),
);
match secrets.get_opt(&target.target.name).await {
Ok(sec_opt) => match sec_opt { Ok(sec_opt) => match sec_opt {
Some(sec) => target_secrets.insert(target.name, sec), Some(sec) => target_secrets.insert(target.name, sec),
None => { None => {
@ -152,12 +177,13 @@ impl ConfigSet {
data: Some(empty_data), data: Some(empty_data),
metadata: ObjectMeta { metadata: ObjectMeta {
name: Some(target.target.name), name: Some(target.target.name),
namespace: self.metadata.namespace.clone(), namespace: Some(namespace.clone()),
owner_references: Some(owner_reference.clone()),
..Default::default() ..Default::default()
}, },
..Default::default() ..Default::default()
}; };
match secrets.create(&PostParams::default(), &new_secret).await { match api.create(&PostParams::default(), &new_secret).await {
Ok(sec) => target_secrets.insert(target.name, sec), Ok(sec) => target_secrets.insert(target.name, sec),
Err(err) => { Err(err) => {
error!("{err}"); error!("{err}");
@ -173,11 +199,8 @@ impl ConfigSet {
}; };
} }
crate::api::v1alpha1::configsets_api::Kinds::ConfigMap => { crate::api::v1alpha1::configsets_api::Kinds::ConfigMap => {
let configmaps: Api<ConfigMap> = Api::namespaced( let api = get_configmap_api(client.clone(), namespace.clone());
ctx.client.clone(), match api.get_opt(&target.target.name).await {
self.metadata.namespace.clone().unwrap().as_str(),
);
match configmaps.get_opt(&target.target.name).await {
Ok(cm_opt) => match cm_opt { Ok(cm_opt) => match cm_opt {
Some(cm) => target_configmaps.insert(target.name, cm), Some(cm) => target_configmaps.insert(target.name, cm),
None => { None => {
@ -186,15 +209,13 @@ impl ConfigSet {
data: Some(empty_data), data: Some(empty_data),
metadata: ObjectMeta { metadata: ObjectMeta {
name: Some(target.target.name), name: Some(target.target.name),
namespace: self.metadata.namespace.clone(), namespace: Some(namespace.clone()),
owner_references: Some(owner_reference.clone()),
..Default::default() ..Default::default()
}, },
..Default::default() ..Default::default()
}; };
match configmaps match api.create(&PostParams::default(), &new_configmap).await {
.create(&PostParams::default(), &new_configmap)
.await
{
Ok(cm) => target_configmaps.insert(target.name, cm), Ok(cm) => target_configmaps.insert(target.name, cm),
Err(err) => { Err(err) => {
error!("{err}"); error!("{err}");
@ -211,17 +232,35 @@ impl ConfigSet {
} }
} }
} }
Ok((target_secrets, target_configmaps))
}
let mut templates: HashMap<String, String> = HashMap::new(); fn build_owner_refenerce(object: ConfigSet) -> Vec<OwnerReference> {
for template in self.spec.templates.clone() { let owner_reference = OwnerReference {
api_version: ConfigSet::api_version(&()).to_string(),
kind: ConfigSet::kind(&()).to_string(),
name: object.metadata.name.unwrap(),
uid: object.metadata.uid.unwrap(),
..Default::default()
};
vec![owner_reference]
}
fn build_templates(
templates: Vec<Templates>,
target_secrets: &mut HashMap<String, Secret>,
target_configmaps: &mut HashMap<String, ConfigMap>,
targets: Vec<TargetWithName>,
inputs: HashMap<String, String>,
) -> Result<()> {
for template in templates {
let reg = Handlebars::new(); let reg = Handlebars::new();
info!("building template {}", template.name); info!("building template {}", template.name);
let var = reg let var = match reg.render_template(template.template.as_str(), &inputs) {
.render_template(template.template.as_str(), &inputs) Ok(var) => var,
.unwrap(); Err(err) => return Err(Error::IllegalConfigSet),
match self };
.spec match targets
.targets
.iter() .iter()
.find(|target| target.name == template.target) .find(|target| target.name == template.target)
.unwrap() .unwrap()
@ -251,12 +290,45 @@ impl ConfigSet {
} }
} }
} }
Ok(())
}
impl ConfigSet {
// Reconcile (for non-finalizer related changes)
async fn reconcile(&self, ctx: Arc<Context>) -> Result<Action> {
/*
* First we need to get inputs and write them to the map
* Then use them to build new values with templates
* And then write those values to targets
*/
let inputs: HashMap<String, String> = gather_inputs(
ctx.client.clone(),
self.metadata.namespace.clone().unwrap(),
self.spec.inputs.clone(),
)
.await?;
let owner_reference = build_owner_refenerce(self.clone());
let (mut target_secrets, mut target_configmaps) = gather_targets(
ctx.client.clone(),
self.metadata.namespace.clone().unwrap(),
self.spec.targets.clone(),
owner_reference,
)
.await?;
build_templates(
self.spec.templates.clone(),
&mut target_secrets,
&mut target_configmaps,
self.spec.targets.clone(),
inputs.clone(),
);
for (_, value) in target_secrets { for (_, value) in target_secrets {
let secrets: Api<Secret> = Api::namespaced( let secrets =
ctx.client.clone(), get_secret_api(ctx.client.clone(), self.metadata.namespace.clone().unwrap());
self.metadata.namespace.clone().unwrap().as_str(),
);
match secrets match secrets
.replace( .replace(
value.metadata.name.clone().unwrap().as_str(), value.metadata.name.clone().unwrap().as_str(),
@ -275,10 +347,8 @@ impl ConfigSet {
}; };
} }
for (_, value) in target_configmaps { for (_, value) in target_configmaps {
let configmaps: Api<ConfigMap> = Api::namespaced( let configmaps =
ctx.client.clone(), get_configmap_api(ctx.client.clone(), self.metadata.namespace.clone().unwrap());
self.metadata.namespace.clone().unwrap().as_str(),
);
match configmaps match configmaps
.replace( .replace(
value.metadata.name.clone().unwrap().as_str(), value.metadata.name.clone().unwrap().as_str(),

View File

@ -12,7 +12,7 @@ use kube::{core::ObjectMeta, CustomResourceExt, ResourceExt};
use crate::api::v1alpha1::configsets_api::ConfigSet; use crate::api::v1alpha1::configsets_api::ConfigSet;
pub fn generate_kube_manifests(namespace: String) { pub fn generate_kube_manifests(namespace: String, image: String, image_tag: String) {
print!("---\n{}", serde_yaml::to_string(&ConfigSet::crd()).unwrap()); print!("---\n{}", serde_yaml::to_string(&ConfigSet::crd()).unwrap());
print!( print!(
"---\n{}", "---\n{}",
@ -29,7 +29,12 @@ pub fn generate_kube_manifests(namespace: String) {
print!( print!(
"---\n{}", "---\n{}",
serde_yaml::to_string(&prepare_deployment(namespace.clone())).unwrap() serde_yaml::to_string(&prepare_deployment(
namespace.clone(),
image.clone(),
image_tag.clone()
))
.unwrap()
) )
} }
@ -119,7 +124,7 @@ fn prepare_cluster_role_binding(namespace: String) -> ClusterRoleBinding {
} }
} }
fn prepare_deployment(namespace: String) -> Deployment { fn prepare_deployment(namespace: String, image: String, image_tag: String) -> Deployment {
let mut labels: BTreeMap<String, String> = BTreeMap::new(); let mut labels: BTreeMap<String, String> = BTreeMap::new();
labels.insert("container".to_string(), "shoebill-controller".to_string()); labels.insert("container".to_string(), "shoebill-controller".to_string());
@ -145,8 +150,8 @@ fn prepare_deployment(namespace: String) -> Deployment {
containers: vec![Container { containers: vec![Container {
command: Some(vec!["/shoebill".to_string()]), command: Some(vec!["/shoebill".to_string()]),
args: Some(vec!["controller".to_string()]), args: Some(vec!["controller".to_string()]),
image: Some("shoebill".to_string()), image: Some(format!("{}:{}", image, image_tag)),
image_pull_policy: Some("Never".to_string()), image_pull_policy: Some("IfNotPresent".to_string()),
name: "shoebill-controller".to_string(), name: "shoebill-controller".to_string(),
env: Some(vec![EnvVar { env: Some(vec![EnvVar {
name: "RUST_LOG".to_string(), name: "RUST_LOG".to_string(),

View File

@ -25,9 +25,11 @@ async fn main() -> anyhow::Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
match &cli.command { match &cli.command {
Commands::Manifests(args) => { Commands::Manifests(args) => helpers::manifests::generate_kube_manifests(
helpers::manifests::generate_kube_manifests(args.namespace.clone()) args.namespace.clone(),
} args.image.clone(),
args.tag.clone(),
),
Commands::Controller(args) => { Commands::Controller(args) => {
// Initiatilize Kubernetes controller state // Initiatilize Kubernetes controller state
let controller = configsets_controller::setup(); let controller = configsets_controller::setup();

View File

@ -10,6 +10,11 @@ spec:
kind: Secret kind: Secret
name: app-connection-string name: app-connection-string
inputs: inputs:
- name: PROTO
from:
kind: ConfigMap
name: database-configmap
key: PROTOCOL
- name: PASSWORD - name: PASSWORD
from: from:
kind: Secret kind: Secret
@ -25,11 +30,6 @@ spec:
kind: Secret kind: Secret
name: database-secret name: database-secret
key: DATABASE key: DATABASE
- name: PROTO
from:
kind: ConfigMap
name: database-configmap
key: PROTOCOL
templates: templates:
- name: CONNECTION - name: CONNECTION
template: "{{ PROTO }}:{{ USERNAME }}:{{ PASSWORD }}/{{ DATABASE }}" template: "{{ PROTO }}:{{ USERNAME }}:{{ PASSWORD }}/{{ DATABASE }}"

14
tests/test.pl Executable file
View File

@ -0,0 +1,14 @@
#!/bin/env sh
# Run shoebill generate and apply
TAG=$(git rev-parse HEAD)
IMAGE="git.badhouseplants.net/allanger/shoebill-operator"
NAMESPACE="test-shoebill-operator"
kubectl create namespace $NAMESPACE
shoebill manifests -i $IMAGE -t $TAG -n $NAMESPACE > /tmp/manifests.yaml
kubectl apply -f /tmp/manifests.yaml
kubectl rollout status -n $NAMESPACE deployment shoebill-controller
kubectl delete -f /tmp/manifests.yaml
kubectl delete namespace $NAMESPACE