First part of actual controller

This commit is contained in:
2025-03-14 04:21:37 +01:00
parent d9cb4b4598
commit 280ac723b5
5 changed files with 536 additions and 114 deletions

View File

@@ -1,111 +1,176 @@
use std::time::Duration;
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use anyhow::anyhow;
use cynic::{http::SurfExt, MutationBuilder, QueryBuilder};
use lldap_controller::lldap::change_password;
use queries::{
AddUserToGroup, AddUserToGroupVariables, CreateManagedUserAttribute, CreateUser,
CreateUserVariables, DeleteUser, DeleteUserVariables, GetUserAttributes, ListManagedUsers,
use futures::StreamExt;
use k8s_openapi::api::core::v1::Secret;
use kube::{
api::{ObjectMeta, Patch, PatchParams, PostParams},
runtime::{
controller::Action,
events::{Event, EventType, Recorder, Reporter},
Controller,
},
Api, Client, Resource,
};
use surf::{Client, Config, Url};
use lldap_controller::resources::{ServiceUser, ServiceUserStatus};
use passwords::PasswordGenerator;
use serde_json::json;
use tracing::{debug, info, instrument, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Registry};
#[derive(thiserror::Error, Debug)]
enum Error {
#[error("Failed to commit secret: {0}")]
Commit(#[source] kube::api::entry::CommitError),
#[error("{0}")]
Kube(#[source] kube::Error),
#[error("MissingObjectKey: {0}")]
MissingObjectKey(&'static str),
}
type Result<T, E = Error> = std::result::Result<T, E>;
struct Data {
client: Client,
recorder: Recorder,
pg: PasswordGenerator,
}
const CONTROLLER_NAME: &str = "lldap.huizinga.dev";
#[instrument(skip(obj, ctx))]
async fn reconcile(obj: Arc<ServiceUser>, ctx: Arc<Data>) -> Result<Action> {
let name = obj
.metadata
.name
.clone()
.ok_or(Error::MissingObjectKey(".metadata.name"))?;
let namespace = obj
.metadata
.namespace
.clone()
.ok_or(Error::MissingObjectKey(".metadata.namespace"))?;
let oref = obj.controller_owner_ref(&()).unwrap();
debug!(name, "reconcile request");
let client = &ctx.client;
let secrets = Api::<Secret>::namespaced(client.clone(), &namespace);
// TODO: Potentially issue: someone modifies the secret and removes the pass
let mut created = false;
let mut secret = secrets
.entry(&name)
.await
.map_err(Error::Kube)?
.or_insert(|| {
debug!(name, "Generating new secret");
let mut contents = BTreeMap::new();
contents.insert("username".into(), name.clone());
contents.insert("password".into(), ctx.pg.generate_one().unwrap());
created = true;
Secret {
metadata: ObjectMeta {
owner_references: Some(vec![oref]),
..Default::default()
},
string_data: Some(contents),
..Default::default()
}
});
secret
.commit(&PostParams {
dry_run: false,
field_manager: Some(CONTROLLER_NAME.into()),
})
.await
.map_err(Error::Commit)?;
let secret = secret;
if created {
debug!(name, "Sending SecretCreated event");
// The reason this is here instead of inside the or_insert is that we
// want to send the event _after_ it successfully committed.
// Also or_insert is not async!
ctx.recorder
.publish(
&Event {
type_: EventType::Normal,
reason: "SecretCreated".into(),
note: Some(format!("Created secret '{name}'")),
action: "NewSecret".into(),
secondary: Some(secret.get().object_ref(&())),
},
&obj.object_ref(&()),
)
.await
.map_err(Error::Kube)?;
}
let service_users = Api::<ServiceUser>::namespaced(client.clone(), &namespace);
let status = json!({
"status": ServiceUserStatus { secret_created: secret.get().meta().creation_timestamp.as_ref().map(|ts| ts.0) }
});
service_users
.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status))
.await
.map_err(Error::Kube)?;
Ok(Action::requeue(Duration::from_secs(3600)))
}
fn error_policy(_obj: Arc<ServiceUser>, err: &Error, _ctx: Arc<Data>) -> Action {
warn!("error: {}", err);
Action::requeue(Duration::from_secs(5))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let token = std::env::var("LLDAP_TOKEN")?;
let logger = tracing_subscriber::fmt::layer().json();
let env_filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("info"))
.unwrap();
let base_url = "http://localhost:17170";
let users = [
"authelia".to_owned(),
"grafana".to_owned(),
"gitea".to_owned(),
];
Registry::default().with(logger).with(env_filter).init();
let client: Client = Config::new()
.set_base_url(Url::parse(base_url)?)
.set_timeout(Some(Duration::from_secs(1)))
.add_header("Authorization", format!("Bearer {token}"))
.map_err(|e| anyhow!(e))?
.try_into()?;
info!("Starting controller");
let operation = GetUserAttributes::build(());
let response = client
.post("/api/graphql")
.run_graphql(operation)
.await
.map_err(|e| anyhow!(e))?;
let client = Client::try_default().await?;
let has_managed = response
.data
.as_ref()
.expect("Should get data")
.schema
.user_schema
.attributes
.iter()
.any(|attr| attr.name == "managed");
let reporter: Reporter = CONTROLLER_NAME.into();
let recorder = Recorder::new(client.clone(), reporter);
if !has_managed {
let operation = CreateManagedUserAttribute::build(());
let _response = client
.post("/api/graphql")
.run_graphql(operation)
.await
.map_err(|e| anyhow!(e))?;
}
let pg = PasswordGenerator::new()
.length(32)
.uppercase_letters(true)
.strict(true);
let operation = ListManagedUsers::build(());
let response = client
.post("/api/graphql")
.run_graphql(operation)
.await
.map_err(|e| anyhow!(e))?;
let service_users = Api::<ServiceUser>::all(client.clone());
let secrets = Api::<Secret>::all(client.clone());
let (existing, remove): (Vec<_>, Vec<_>) = response
.data
.expect("Should get data")
.users
.into_iter()
.map(|user| user.id)
.partition(|user| users.contains(user));
let (update, create): (Vec<_>, Vec<_>) = users.iter().partition(|user| existing.contains(user));
for id in &remove {
println!("Removing '{id}");
let operation = DeleteUser::build(DeleteUserVariables { id });
let _response = client
.post("/api/graphql")
.run_graphql(operation)
.await
.map_err(|e| anyhow!(e))?;
}
for id in create {
println!("Creating '{id}'");
let operation = CreateUser::build(CreateUserVariables { id });
let _response = client
.post("/api/graphql")
.run_graphql(operation)
.await
.map_err(|e| anyhow!(e))?;
let operation = AddUserToGroup::build(AddUserToGroupVariables { id, group: 3 });
let _response = client
.post("/api/graphql")
.run_graphql(operation)
.await
.map_err(|e| anyhow!(e))?;
change_password(&client, id, "JustATest").await?;
}
for id in update {
println!("Updating '{id}'");
change_password(&client, id, "JustATest").await?;
}
Controller::new(service_users.clone(), Default::default())
.owns(secrets, Default::default())
.shutdown_on_signal()
.run(
reconcile,
error_policy,
Arc::new(Data {
client,
recorder,
pg,
}),
)
.for_each(|res| async move {
match res {
Ok(obj) => debug!("reconciled {:?}", obj.0.name),
Err(err) => warn!("reconcile failed: {}", err),
}
})
.await;
Ok(())
}

View File

@@ -1,3 +1,4 @@
use chrono::{DateTime, Utc};
use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -13,9 +14,9 @@ use serde::{Deserialize, Serialize};
#[kube(
shortname = "lsu",
doc = "Custom resource for managing Service Users inside of LLDAP",
printcolumn = r#"{"name":"Exists", "type":"boolean", "description":"Does the service user exist in LLDAP", "jsonPath":".status.exists"}"#,
printcolumn = r#"{"name":"Manager", "type":"boolean", "description":"Can the service user manage passwords", "jsonPath":".spec.passwordManager"}"#,
printcolumn = r#"{"name":"Age", "type":"date", "jsonPath":".metadata.creationTimestamp"}"#
printcolumn = r#"{"name":"Age", "type":"date", "jsonPath":".metadata.creationTimestamp"}"#,
printcolumn = r#"{"name":"Secret", "type":"date", "description":"Secret creation timestamp", "jsonPath":".status.secret_created"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct ServiceUserSpec {
@@ -27,7 +28,7 @@ pub struct ServiceUserSpec {
#[derive(Deserialize, Serialize, Clone, Default, Debug, JsonSchema)]
pub struct ServiceUserStatus {
pub exists: bool,
pub secret_created: Option<DateTime<Utc>>,
}
#[cfg(test)]

View File

@@ -18,10 +18,6 @@ spec:
scope: Namespaced
versions:
- additionalPrinterColumns:
- description: Does the service user exist in LLDAP
jsonPath: ".status.exists"
name: Exists
type: boolean
- description: Can the service user manage passwords
jsonPath: ".spec.passwordManager"
name: Manager
@@ -29,6 +25,10 @@ spec:
- jsonPath: ".metadata.creationTimestamp"
name: Age
type: date
- description: Secret creation timestamp
jsonPath: ".status.secret_created"
name: Secret
type: date
name: v1
schema:
openAPIV3Schema:
@@ -48,10 +48,10 @@ spec:
status:
nullable: true
properties:
exists:
type: boolean
required:
- exists
secret_created:
format: date-time
nullable: true
type: string
type: object
required:
- spec