First Commit
The main feature of the operator is implemented, but it's not ready for real use yet.
This commit is contained in:
commit
e857c359e0
1
.containerignore
Normal file
1
.containerignore
Normal file
@ -0,0 +1 @@
|
||||
target
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/target
|
||||
image.tar
|
2499
Cargo.lock
generated
Normal file
2499
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
Cargo.toml
Normal file
35
Cargo.toml
Normal file
@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "shoebill-operator"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
default-run = "shoebill"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
[[bin]]
|
||||
doc = false
|
||||
name = "shoebill"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "controller"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.32.0", features = ["macros", "rt-multi-thread"] }
|
||||
k8s-openapi = { version = "0.20.0", features = ["latest"] }
|
||||
serde = { version = "1.0.185", features = ["derive"] }
|
||||
serde_json = "1.0.105"
|
||||
serde_yaml = "0.9.25"
|
||||
anyhow = "1.0.75"
|
||||
clap = { version = "4.4.8", features = ["derive", "env"] }
|
||||
kube = { version = "0.87.1", features = ["derive", "runtime", "client"] }
|
||||
schemars = { version = "0.8.12", features = ["chrono"] }
|
||||
chrono = { version = "0.4.26", features = ["serde"] }
|
||||
futures = "0.3.29"
|
||||
thiserror = "1.0.50"
|
||||
actix-web = "4.4.0"
|
||||
log = "0.4.20"
|
||||
env_logger = "0.10.1"
|
||||
base64 = "0.21.5"
|
||||
handlebars = "4.5.0"
|
||||
kube-client = "0.87.1"
|
9
Containerfile
Normal file
9
Containerfile
Normal file
@ -0,0 +1,9 @@
|
||||
FROM rust:1.74.0-alpine3.18 AS builder
|
||||
RUN apk update && apk add --no-cache musl-dev
|
||||
WORKDIR /src
|
||||
COPY . /src
|
||||
RUN cargo build --release
|
||||
|
||||
FROM alpine
|
||||
COPY --from=builder /src/target/release/shoebill /shoebill
|
||||
ENTRYPOINT /shoebill
|
106
README.md
Normal file
106
README.md
Normal file
@ -0,0 +1,106 @@
|
||||
# Shoebill
|
||||
|
||||
## !! Be careful !!
|
||||
|
||||
The code is not ready for real use, it can only create secrets, but a lot of errors are not handled, so they are making the controller die, and there is no clean-up at all. Also, reconciliation doesn't work as I would like to work yet. I hope that I'll release the first prod-ready version soon
|
||||
|
||||
## What's that?
|
||||
|
||||
It's a **Kubernetes** operator that lets you build new **Secrets** and **ConfigMaps** using ones that exist already as inputs for templates.
|
||||
|
||||
## Why does that exist?
|
||||
|
||||
I'm one of maintainers of [db-operator](https://github.com/db-operator/db-operator), and there we have implemented a feature that we call **templated credentials**, it lets user define templates that should be used for creating new entries to **Secrets** and **ConfigMaps** that are managed by the operator. Because sometimes you need to have more than just credentials, cause your application may require a custom connection string. But since this feature doesn't exist in any operator, I've created another operator for exactly that.
|
||||
|
||||
Let's say you have an operator **some-operator** that should run something that is required for your application to run, and when you apply the CR, the operator is creating something that results in a **Secret** like that:
|
||||
|
||||
```yaml
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: some-secret
|
||||
stringData:
|
||||
password: really-strong-one
|
||||
```
|
||||
|
||||
and a **ConfigMap**:
|
||||
|
||||
```yaml
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: some-configmap
|
||||
data:
|
||||
username: application-user
|
||||
hostname: some.app.rocks
|
||||
```
|
||||
|
||||
But to use that something, your application require an environment variable in a format like this:
|
||||
|
||||
```bash
|
||||
SOME_CONNECTION_STRING=${USERNAME}:${PASSWORD}@{$HOSTNAME}
|
||||
```
|
||||
|
||||
What are your options?
|
||||
|
||||
- You can get the data from the **Secret** and **ConfigMap** to build a new **Secret** manually and add it as an env var to your application **Deployment**
|
||||
- You can write an `initContainer` that will get the data from those sources, and create a formatted connection string, that later might be somehow set as an environment var for you main workload
|
||||
- You can have a watcher that is checking those sources and modifies you workload object, setting the desired env
|
||||
- _Or maybe you can use something that exists already, but I wanted to try writing an operator in Rust, so I don't care too much_
|
||||
|
||||
With this operator, you can create a **Custom Resource** called **ConfigSet**, that in our case should look like that:
|
||||
|
||||
```yaml
|
||||
kind: ConfigSet
|
||||
spec:
|
||||
inputs:
|
||||
- name: PASSWORD
|
||||
from:
|
||||
kind: Secret
|
||||
name: some-secret
|
||||
key: password
|
||||
- name: USERNAME
|
||||
from:
|
||||
kind: ConfigMap
|
||||
name: some-configmap
|
||||
key: username
|
||||
- name: HOSTNAME
|
||||
from:
|
||||
kind: ConfigMap
|
||||
name: somet-configmap
|
||||
key: hostname
|
||||
targets:
|
||||
- name: app-some-creds
|
||||
target:
|
||||
kind: Secret
|
||||
name: app-some-creds
|
||||
templates:
|
||||
- name: SOME_CONNECTION_STRING
|
||||
template: "{{USERNAME}}:{{PASSWORD}}@{{HOSTNAME}}"
|
||||
```
|
||||
|
||||
And after you apply it, there will be a new secret created (or the existing one will be modified), and it will contain
|
||||
|
||||
```yaml
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: app-some-creds
|
||||
stringData:
|
||||
SOME_CONNECTION_STRING: application-user:really-strong-one@some.app.rocks
|
||||
```
|
||||
|
||||
Now you can simply mount that newly created secret to your workload, and that's it.
|
||||
|
||||
## How can I start using it?
|
||||
|
||||
Once it's production ready, I'll start distributing it as a **helm** chart. Currently, since it's should only be used by those one who are developing it, it looks like that
|
||||
|
||||
- build an image
|
||||
- import that image to you K8s
|
||||
- build the tool locally (or use the image too)
|
||||
- run `shoebill manifests > /tmp/manifests.yaml`, it will generate all the required manifests for the quick start
|
||||
- apply those manifests, and check if the controller is up
|
||||
- prepare you secrets and configmaps (or go to `./yaml/example` folder and use manifests from there
|
||||
- create you `ConfigSet` manifests and apply it too. Example also can be found in `./yaml/example` dir
|
||||
|
||||
## Why Shoebill?
|
||||
|
||||
There is no real connection between the project and the name, I just always wanted to have a project called **Shoebill** because I really like those birds
|
1
src/api/mod.rs
Normal file
1
src/api/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod v1alpha1;
|
75
src/api/v1alpha1/configsets_api.rs
Normal file
75
src/api/v1alpha1/configsets_api.rs
Normal file
@ -0,0 +1,75 @@
|
||||
use futures::StreamExt;
|
||||
use kube::api::ListParams;
|
||||
use kube::runtime::controller::Action;
|
||||
use kube::runtime::watcher::Config;
|
||||
use kube::runtime::Controller;
|
||||
use kube::{Api, Client, CustomResource};
|
||||
use log::*;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
|
||||
/// ConfigSet is the main CRD of the shoebill-operator.
|
||||
/// During the reconciliation, the controller will get the data
|
||||
/// from Secrets and ConfigMaps defined in inputs, use them for
|
||||
/// building new variables, that are defined in templates, and
|
||||
/// put them to target Secrets or ConfigMaps
|
||||
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)]
|
||||
#[cfg_attr(test, derive(Default))]
|
||||
#[kube(
|
||||
kind = "ConfigSet",
|
||||
group = "shoebill.badhouseplants.net",
|
||||
version = "v1alpha1",
|
||||
namespaced
|
||||
)]
|
||||
#[kube(status = "ConfigSetStatus", shortname = "confset")]
|
||||
pub struct ConfigSetSpec {
|
||||
pub targets: Vec<TargetWithName>,
|
||||
pub inputs: Vec<InputWithName>,
|
||||
pub templates: Vec<Templates>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
|
||||
pub struct ConfigSetStatus {
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
|
||||
pub struct TargetWithName {
|
||||
pub name: String,
|
||||
pub target: Target,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
|
||||
pub struct Target {
|
||||
pub kind: Kinds,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
|
||||
pub struct InputWithName {
|
||||
pub name: String,
|
||||
pub from: Input,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
|
||||
pub enum Kinds {
|
||||
Secret,
|
||||
ConfigMap,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
|
||||
pub struct Input {
|
||||
pub kind: Kinds,
|
||||
pub name: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)]
|
||||
pub struct Templates {
|
||||
pub name: String,
|
||||
pub template: String,
|
||||
pub target: String,
|
||||
}
|
1
src/api/v1alpha1/mod.rs
Normal file
1
src/api/v1alpha1/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod configsets_api;
|
9
src/cmd/controller.rs
Normal file
9
src/cmd/controller.rs
Normal file
@ -0,0 +1,9 @@
|
||||
use clap::Args;
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ControllerArgs {
|
||||
/// Use this flag if you want to let shoebill
|
||||
/// update secrets that already exist in the cluster
|
||||
#[arg(long, default_value_t = false, env = "SHOEBILL_ALLOW_EXISTING")]
|
||||
pub(crate) allow_existing: bool,
|
||||
}
|
7
src/cmd/manifests.rs
Normal file
7
src/cmd/manifests.rs
Normal file
@ -0,0 +1,7 @@
|
||||
use clap::{Args, Command, Parser, Subcommand};
|
||||
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ManifestsArgs {
|
||||
#[arg(long, short, default_value = "default")]
|
||||
pub(crate) namespace: String,
|
||||
}
|
23
src/cmd/mod.rs
Normal file
23
src/cmd/mod.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use clap::{command, Parser, Subcommand};
|
||||
|
||||
use self::controller::ControllerArgs;
|
||||
use self::manifests::ManifestsArgs;
|
||||
|
||||
pub(crate) mod controller;
|
||||
pub(crate) mod manifests;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
#[command(propagate_version = true)]
|
||||
pub(crate) struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum Commands {
|
||||
// Start the controller
|
||||
Controller(ControllerArgs),
|
||||
// Generate manifests for quick install
|
||||
Manifests(ManifestsArgs),
|
||||
}
|
308
src/controllers/configsets_controller.rs
Normal file
308
src/controllers/configsets_controller.rs
Normal file
@ -0,0 +1,308 @@
|
||||
use crate::api::v1alpha1::configsets_api::ConfigSet;
|
||||
use futures::StreamExt;
|
||||
use handlebars::Handlebars;
|
||||
use k8s_openapi::api::core::v1::{ConfigMap, Secret};
|
||||
use k8s_openapi::ByteString;
|
||||
use kube::api::{ListParams, PostParams};
|
||||
use kube::core::{Object, ObjectMeta};
|
||||
use kube::error::ErrorResponse;
|
||||
use kube::runtime::controller::Action;
|
||||
use kube::runtime::watcher::Config;
|
||||
use kube::runtime::Controller;
|
||||
use kube::{Api, Client, CustomResource};
|
||||
use log::*;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::str::{from_utf8, Utf8Error};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("SerializationError: {0}")]
|
||||
SerializationError(#[source] serde_json::Error),
|
||||
|
||||
#[error("Kube Error: {0}")]
|
||||
KubeError(#[source] kube::Error),
|
||||
|
||||
#[error("Finalizer Error: {0}")]
|
||||
// NB: awkward type because finalizer::Error embeds the reconciler error (which is this)
|
||||
// so boxing this error to break cycles
|
||||
FinalizerError(#[source] Box<kube::runtime::finalizer::Error<Error>>),
|
||||
|
||||
#[error("IllegalDocument")]
|
||||
IllegalDocument,
|
||||
}
|
||||
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
impl Error {
|
||||
pub fn metric_label(&self) -> String {
|
||||
format!("{self:?}").to_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
// Context for our reconciler
|
||||
#[derive(Clone)]
|
||||
pub struct Context {
|
||||
/// Kubernetes client
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
async fn reconcile(csupstream: Arc<ConfigSet>, ctx: Arc<Context>) -> Result<Action> {
|
||||
let cs = csupstream.clone();
|
||||
info!(
|
||||
"reconciling {} - {}",
|
||||
cs.metadata.name.clone().unwrap(),
|
||||
cs.metadata.namespace.clone().unwrap()
|
||||
);
|
||||
match cs.metadata.deletion_timestamp {
|
||||
Some(_) => return cs.cleanup(ctx).await,
|
||||
None => return cs.reconcile(ctx).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the controller and shared state (given the crd is installed)
|
||||
pub async fn setup() {
|
||||
info!("starting the configset controller");
|
||||
let client = Client::try_default()
|
||||
.await
|
||||
.expect("failed to create kube Client");
|
||||
let docs = Api::<ConfigSet>::all(client.clone());
|
||||
if let Err(e) = docs.list(&ListParams::default().limit(1)).await {
|
||||
error!("{}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let ctx = Arc::new(Context { client });
|
||||
Controller::new(docs, Config::default().any_semantic())
|
||||
.shutdown_on_signal()
|
||||
.run(reconcile, error_policy, ctx)
|
||||
.filter_map(|x| async move { std::result::Result::ok(x) })
|
||||
.for_each(|_| futures::future::ready(()))
|
||||
.await;
|
||||
}
|
||||
|
||||
fn error_policy(doc: Arc<ConfigSet>, error: &Error, ctx: Arc<Context>) -> Action {
|
||||
Action::requeue(Duration::from_secs(5 * 60))
|
||||
}
|
||||
|
||||
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 mut inputs: HashMap<String, String> = HashMap::new();
|
||||
for input in self.spec.inputs.clone() {
|
||||
info!("populating data from input {}", input.name);
|
||||
match input.from.kind {
|
||||
crate::api::v1alpha1::configsets_api::Kinds::Secret => {
|
||||
let secrets: Api<Secret> = Api::namespaced(
|
||||
ctx.client.clone(),
|
||||
self.metadata.namespace.clone().unwrap().as_str(),
|
||||
);
|
||||
let secret: String = match secrets.get(&input.from.name).await {
|
||||
Ok(s) => from_utf8(&s.data.clone().unwrap()[input.from.key.as_str()].0)
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
Err(err) => {
|
||||
error!("{err}");
|
||||
return Err(Error::KubeError(err));
|
||||
}
|
||||
};
|
||||
inputs.insert(input.from.key, secret);
|
||||
}
|
||||
crate::api::v1alpha1::configsets_api::Kinds::ConfigMap => {
|
||||
let configmaps: Api<ConfigMap> = Api::namespaced(
|
||||
ctx.client.clone(),
|
||||
self.metadata.namespace.clone().unwrap().as_str(),
|
||||
);
|
||||
let configmap: String = match configmaps.get(&input.from.name).await {
|
||||
Ok(cm) => {
|
||||
let data = &cm.data.unwrap()[input.from.key.as_str()];
|
||||
data.to_string()
|
||||
}
|
||||
Err(err) => {
|
||||
error!("{err}");
|
||||
return Err(Error::KubeError(err));
|
||||
}
|
||||
};
|
||||
inputs.insert(input.name, configmap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut target_secrets: HashMap<String, Secret> = HashMap::new();
|
||||
let mut target_configmaps: HashMap<String, ConfigMap> = HashMap::new();
|
||||
|
||||
for target in self.spec.targets.clone() {
|
||||
match target.target.kind {
|
||||
crate::api::v1alpha1::configsets_api::Kinds::Secret => {
|
||||
let secrets: Api<Secret> = Api::namespaced(
|
||||
ctx.client.clone(),
|
||||
self.metadata.namespace.clone().unwrap().as_str(),
|
||||
);
|
||||
match secrets.get_opt(&target.target.name).await {
|
||||
Ok(sec_opt) => match sec_opt {
|
||||
Some(sec) => target_secrets.insert(target.name, sec),
|
||||
None => {
|
||||
let empty_data: BTreeMap<String, ByteString> = BTreeMap::new();
|
||||
let new_secret: Secret = Secret {
|
||||
data: Some(empty_data),
|
||||
metadata: ObjectMeta {
|
||||
name: Some(target.target.name),
|
||||
namespace: self.metadata.namespace.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
match secrets.create(&PostParams::default(), &new_secret).await {
|
||||
Ok(sec) => target_secrets.insert(target.name, sec),
|
||||
Err(err) => {
|
||||
error!("{err}");
|
||||
return Err(Error::KubeError(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
error!("{err}");
|
||||
return Err(Error::KubeError(err));
|
||||
}
|
||||
};
|
||||
}
|
||||
crate::api::v1alpha1::configsets_api::Kinds::ConfigMap => {
|
||||
let configmaps: Api<ConfigMap> = Api::namespaced(
|
||||
ctx.client.clone(),
|
||||
self.metadata.namespace.clone().unwrap().as_str(),
|
||||
);
|
||||
match configmaps.get_opt(&target.target.name).await {
|
||||
Ok(cm_opt) => match cm_opt {
|
||||
Some(cm) => target_configmaps.insert(target.name, cm),
|
||||
None => {
|
||||
let empty_data: BTreeMap<String, String> = BTreeMap::new();
|
||||
let new_configmap: ConfigMap = ConfigMap {
|
||||
data: Some(empty_data),
|
||||
metadata: ObjectMeta {
|
||||
name: Some(target.target.name),
|
||||
namespace: self.metadata.namespace.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
match configmaps
|
||||
.create(&PostParams::default(), &new_configmap)
|
||||
.await
|
||||
{
|
||||
Ok(cm) => target_configmaps.insert(target.name, cm),
|
||||
Err(err) => {
|
||||
error!("{err}");
|
||||
return Err(Error::KubeError(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
error!("{err}");
|
||||
return Err(Error::KubeError(err));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut templates: HashMap<String, String> = HashMap::new();
|
||||
for template in self.spec.templates.clone() {
|
||||
let reg = Handlebars::new();
|
||||
info!("building template {}", template.name);
|
||||
let var = reg
|
||||
.render_template(template.template.as_str(), &inputs)
|
||||
.unwrap();
|
||||
info!("result is {}", var);
|
||||
match self
|
||||
.spec
|
||||
.targets
|
||||
.iter()
|
||||
.find(|target| target.name == template.target)
|
||||
.unwrap()
|
||||
.target
|
||||
.kind
|
||||
{
|
||||
crate::api::v1alpha1::configsets_api::Kinds::Secret => {
|
||||
let sec = target_secrets.get_mut(&template.target).unwrap();
|
||||
let mut byte_var: ByteString = ByteString::default();
|
||||
byte_var.0 = var.as_bytes().to_vec();
|
||||
|
||||
let mut existing_data = match sec.clone().data {
|
||||
Some(sec) => sec,
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
existing_data.insert(template.name, byte_var);
|
||||
sec.data = Some(existing_data);
|
||||
}
|
||||
crate::api::v1alpha1::configsets_api::Kinds::ConfigMap => {
|
||||
let cm = target_configmaps.get_mut(&template.target).unwrap();
|
||||
let mut existing_data = match cm.clone().data {
|
||||
Some(cm) => cm,
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
existing_data.insert(template.name, var);
|
||||
cm.data = Some(existing_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (_, value) in target_secrets {
|
||||
let secrets: Api<Secret> = Api::namespaced(
|
||||
ctx.client.clone(),
|
||||
self.metadata.namespace.clone().unwrap().as_str(),
|
||||
);
|
||||
match secrets
|
||||
.replace(
|
||||
value.metadata.name.clone().unwrap().as_str(),
|
||||
&PostParams::default(),
|
||||
&value,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(sec) => {
|
||||
info!("secret {} is updated", sec.metadata.name.unwrap());
|
||||
}
|
||||
Err(err) => {
|
||||
error!("{}", err);
|
||||
return Err(Error::KubeError(err));
|
||||
}
|
||||
};
|
||||
}
|
||||
for (_, value) in target_configmaps {
|
||||
let configmaps: Api<ConfigMap> = Api::namespaced(
|
||||
ctx.client.clone(),
|
||||
self.metadata.namespace.clone().unwrap().as_str(),
|
||||
);
|
||||
match configmaps
|
||||
.replace(
|
||||
value.metadata.name.clone().unwrap().as_str(),
|
||||
&PostParams::default(),
|
||||
&value,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(sec) => {
|
||||
info!("secret {} is updated", sec.metadata.name.unwrap());
|
||||
}
|
||||
Err(err) => {
|
||||
error!("{}", err);
|
||||
return Err(Error::KubeError(err));
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok::<Action, Error>(Action::await_change())
|
||||
}
|
||||
|
||||
// Finalizer cleanup (the object was deleted, ensure nothing is orphaned)
|
||||
async fn cleanup(&self, ctx: Arc<Context>) -> Result<Action> {
|
||||
info!("removing, not installing");
|
||||
Ok::<Action, Error>(Action::await_change())
|
||||
}
|
||||
}
|
1
src/controllers/mod.rs
Normal file
1
src/controllers/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub(crate) mod configsets_controller;
|
166
src/helpers/manifests.rs
Normal file
166
src/helpers/manifests.rs
Normal file
@ -0,0 +1,166 @@
|
||||
use std::{collections::BTreeMap, default};
|
||||
|
||||
use k8s_openapi::{
|
||||
api::{
|
||||
apps::v1::{Deployment, DeploymentSpec},
|
||||
core::v1::{Container, EnvVar, PodSpec, PodTemplate, PodTemplateSpec, ServiceAccount},
|
||||
rbac::v1::{ClusterRole, ClusterRoleBinding, PolicyRule, Role, RoleRef, Subject},
|
||||
},
|
||||
apimachinery::pkg::apis::meta::v1::LabelSelector,
|
||||
};
|
||||
use kube::{core::ObjectMeta, CustomResourceExt, ResourceExt};
|
||||
|
||||
use crate::api::v1alpha1::configsets_api::ConfigSet;
|
||||
|
||||
pub fn generate_kube_manifests(namespace: String) {
|
||||
print!("---\n{}", serde_yaml::to_string(&ConfigSet::crd()).unwrap());
|
||||
print!(
|
||||
"---\n{}",
|
||||
serde_yaml::to_string(&prepare_cluster_role(namespace.clone())).unwrap()
|
||||
);
|
||||
print!(
|
||||
"---\n{}",
|
||||
serde_yaml::to_string(&prepare_service_account(namespace.clone())).unwrap()
|
||||
);
|
||||
print!(
|
||||
"---\n{}",
|
||||
serde_yaml::to_string(&prepare_cluster_role_binding(namespace.clone())).unwrap()
|
||||
);
|
||||
|
||||
print!(
|
||||
"---\n{}",
|
||||
serde_yaml::to_string(&prepare_deployment(namespace.clone())).unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
fn prepare_cluster_role(namespace: String) -> ClusterRole {
|
||||
let rules: Vec<PolicyRule> = vec![
|
||||
PolicyRule {
|
||||
api_groups: Some(vec!["shoebill.badhouseplants.net".to_string()]),
|
||||
resources: Some(vec!["configsets".to_string()]),
|
||||
verbs: vec![
|
||||
"get".to_string(),
|
||||
"list".to_string(),
|
||||
"patch".to_string(),
|
||||
"update".to_string(),
|
||||
"watch".to_string(),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
PolicyRule {
|
||||
api_groups: Some(vec!["shoebill.badhouseplants.net".to_string()]),
|
||||
resources: Some(vec!["configsets/finalizers".to_string()]),
|
||||
verbs: vec![
|
||||
"get".to_string(),
|
||||
"list".to_string(),
|
||||
"patch".to_string(),
|
||||
"update".to_string(),
|
||||
"watch".to_string(),
|
||||
"create".to_string(),
|
||||
"delete".to_string(),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
PolicyRule {
|
||||
api_groups: Some(vec!["".to_string()]),
|
||||
resources: Some(vec!["secrets".to_string(), "configmaps".to_string()]),
|
||||
verbs: vec![
|
||||
"get".to_string(),
|
||||
"list".to_string(),
|
||||
"watch".to_string(),
|
||||
"update".to_string(),
|
||||
"create".to_string(),
|
||||
"delete".to_string(),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
ClusterRole {
|
||||
metadata: ObjectMeta {
|
||||
name: Some("shoebill-controller".to_string()),
|
||||
namespace: Some(namespace),
|
||||
..Default::default()
|
||||
},
|
||||
rules: Some(rules),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_service_account(namespace: String) -> ServiceAccount {
|
||||
ServiceAccount {
|
||||
metadata: ObjectMeta {
|
||||
name: Some("shoebill-controller".to_string()),
|
||||
namespace: Some(namespace),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_cluster_role_binding(namespace: String) -> ClusterRoleBinding {
|
||||
ClusterRoleBinding {
|
||||
metadata: ObjectMeta {
|
||||
name: Some("shoebill-controller".to_string()),
|
||||
namespace: Some(namespace.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
role_ref: RoleRef {
|
||||
api_group: "rbac.authorization.k8s.io".to_string(),
|
||||
kind: "ClusterRole".to_string(),
|
||||
name: "shoebill-controller".to_string(),
|
||||
},
|
||||
subjects: Some(vec![Subject {
|
||||
kind: "ServiceAccount".to_string(),
|
||||
name: "shoebill-controller".to_string(),
|
||||
namespace: Some(namespace.clone()),
|
||||
..Default::default()
|
||||
}]),
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_deployment(namespace: String) -> Deployment {
|
||||
let mut labels: BTreeMap<String, String> = BTreeMap::new();
|
||||
labels.insert("container".to_string(), "shoebill-controller".to_string());
|
||||
|
||||
Deployment {
|
||||
metadata: ObjectMeta {
|
||||
name: Some("shoebill-controller".to_string()),
|
||||
namespace: Some(namespace.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
spec: Some(DeploymentSpec {
|
||||
replicas: Some(1),
|
||||
selector: LabelSelector {
|
||||
match_labels: Some(labels.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
template: PodTemplateSpec {
|
||||
metadata: Some(ObjectMeta {
|
||||
labels: Some(labels.clone()),
|
||||
..Default::default()
|
||||
}),
|
||||
spec: Some(PodSpec {
|
||||
automount_service_account_token: Some(true),
|
||||
containers: vec![Container {
|
||||
command: Some(vec!["/shoebill".to_string()]),
|
||||
args: Some(vec!["controller".to_string()]),
|
||||
image: Some("shoebill".to_string()),
|
||||
image_pull_policy: Some("Never".to_string()),
|
||||
name: "shoebill-controller".to_string(),
|
||||
env: Some(vec![EnvVar {
|
||||
name: "RUST_LOG".to_string(),
|
||||
value: Some("info".to_string()),
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
}],
|
||||
service_account_name: Some("shoebill-controller".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
},
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
1
src/helpers/mod.rs
Normal file
1
src/helpers/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub(crate) mod manifests;
|
24
src/lib.rs
Normal file
24
src/lib.rs
Normal file
@ -0,0 +1,24 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("SerializationError: {0}")]
|
||||
SerializationError(#[source] serde_json::Error),
|
||||
|
||||
#[error("Kube Error: {0}")]
|
||||
KubeError(#[source] kube::Error),
|
||||
|
||||
#[error("Finalizer Error: {0}")]
|
||||
// NB: awkward type because finalizer::Error embeds the reconciler error (which is this)
|
||||
// so boxing this error to break cycles
|
||||
FinalizerError(#[source] Box<kube::runtime::finalizer::Error<Error>>),
|
||||
|
||||
#[error("IllegalDocument")]
|
||||
IllegalDocument,
|
||||
}
|
||||
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
impl Error {
|
||||
pub fn metric_label(&self) -> String {
|
||||
format!("{self:?}").to_lowercase()
|
||||
}
|
||||
}
|
55
src/main.rs
Normal file
55
src/main.rs
Normal file
@ -0,0 +1,55 @@
|
||||
#![allow(unused_imports, unused_variables)]
|
||||
use std::process::exit;
|
||||
|
||||
use actix_web::{
|
||||
get, middleware, web::Data, App, HttpRequest, HttpResponse, HttpServer, Responder,
|
||||
};
|
||||
use clap::{Args, Command, Parser, Subcommand};
|
||||
use cmd::{Cli, Commands};
|
||||
use controllers::configsets_controller;
|
||||
use log::*;
|
||||
mod api;
|
||||
mod cmd;
|
||||
mod controllers;
|
||||
mod helpers;
|
||||
|
||||
#[get("/")]
|
||||
async fn index(req: HttpRequest) -> impl Responder {
|
||||
let d = "Shoebill";
|
||||
HttpResponse::Ok().json(&d)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
env_logger::init();
|
||||
let cli = Cli::parse();
|
||||
|
||||
match &cli.command {
|
||||
Commands::Manifests(args) => {
|
||||
helpers::manifests::generate_kube_manifests(args.namespace.clone())
|
||||
}
|
||||
Commands::Controller(args) => {
|
||||
// Initiatilize Kubernetes controller state
|
||||
let controller = configsets_controller::setup();
|
||||
// Start web server
|
||||
let server =
|
||||
match HttpServer::new(move || App::new().service(index)).bind("0.0.0.0:8080") {
|
||||
Ok(server) => server.shutdown_timeout(5),
|
||||
Err(err) => {
|
||||
error!("{}", err);
|
||||
exit(1)
|
||||
}
|
||||
};
|
||||
// Both runtimes implements graceful shutdown, so poll until both are done
|
||||
match tokio::join!(controller, server.run()).1 {
|
||||
Ok(res) => info!("server is started"),
|
||||
Err(err) => {
|
||||
error!("{}", err);
|
||||
exit(1)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
6
yaml/example/configmap.yaml
Normal file
6
yaml/example/configmap.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: database-configmap
|
||||
data:
|
||||
PROTOCOL: postgresql
|
44
yaml/example/example.yaml
Normal file
44
yaml/example/example.yaml
Normal file
@ -0,0 +1,44 @@
|
||||
---
|
||||
apiVersion: shoebill.badhouseplants.net/v1alpha1
|
||||
kind: ConfigSet
|
||||
metadata:
|
||||
name: test
|
||||
spec:
|
||||
targets:
|
||||
- name: app-connection-string
|
||||
target:
|
||||
kind: Secret
|
||||
name: app-connection-string
|
||||
inputs:
|
||||
- name: PASSWORD
|
||||
from:
|
||||
kind: Secret
|
||||
name: database-secret
|
||||
key: PASSWORD
|
||||
- name: USERNAME
|
||||
from:
|
||||
kind: Secret
|
||||
name: database-secret
|
||||
key: USERNAME
|
||||
- name: DATABASE
|
||||
from:
|
||||
kind: Secret
|
||||
name: database-secret
|
||||
key: DATABASE
|
||||
- name: PROTO
|
||||
from:
|
||||
kind: ConfigMap
|
||||
name: database-configmap
|
||||
key: PROTOCOL
|
||||
templates:
|
||||
- name: CONNECTION
|
||||
template: "{{ PROTO }}:{{ USERNAME }}:{{ PASSWORD }}/{{ DATABASE }}"
|
||||
target: app-connection-string
|
||||
- name: IS_POSTGRES
|
||||
template: |
|
||||
{{#if (eq PROTO "postgresql") }}
|
||||
true
|
||||
{{ else }}
|
||||
false
|
||||
{{/if}}
|
||||
target: app-connection-string
|
8
yaml/example/secret.yaml
Normal file
8
yaml/example/secret.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: database-secret
|
||||
stringData:
|
||||
PASSWORD: 123123!!
|
||||
USERNAME: real_root
|
||||
DATABASE: postgres
|
Loading…
Reference in New Issue
Block a user