Compare commits

..

11 Commits

Author SHA1 Message Date
cdb493d08b feat: Generate crds in Dockerfile
Some checks failed
Build and deploy / build (push) Failing after 11m40s
2025-12-21 07:04:49 +01:00
f1005138f4 feat: Update workflow to use docker bake 2025-12-21 06:41:04 +01:00
72bb27aae0 chore: Update dependencies 2025-12-21 06:19:04 +01:00
f98677eb1e Trigger reconcile webhook once build is done
All checks were successful
kustomization/lldap-controller/b5ae0585 reconciliation succeeded
Build and deploy / build (push) Successful in 7m36s
2025-04-23 21:00:10 +02:00
a6b342ea62 Use reusable workflow
All checks were successful
Build and deploy / build (push) Successful in 7m34s
kustomization/lldap-controller/b5ae0585 reconciliation succeeded
2025-04-23 14:15:19 +02:00
6bedf95348 Notify on build failure
All checks were successful
Build and deploy / Build container and manifests (push) Successful in 5m54s
kustomization/lldap-controller/b5ae0585 reconciliation succeeded
2025-04-22 18:24:06 +02:00
8a07d661ce Added option to load lldap password from file (#12)
All checks were successful
Build and deploy / Build container and manifests (push) Successful in 6m15s
kustomization/lldap-controller/b5ae0585 reconciliation succeeded
2025-04-22 11:13:46 +02:00
f714773ba8 Use custom error for missing environment variables
Some checks failed
Build and deploy / Build container and manifests (push) Has been cancelled
2025-04-22 10:44:25 +02:00
bb09334fad Include bind_dn field in secet (#13)
All checks were successful
Build and deploy / Build container and manifests (push) Successful in 7m8s
2025-04-22 00:34:50 +02:00
58bb0b312a Added dotenvy to make development a bit easier 2025-04-22 00:22:32 +02:00
46ea8e2cd7 Switched from anyhow to color_eyre 2025-04-22 00:22:30 +02:00
18 changed files with 953 additions and 899 deletions

View File

@@ -1,2 +0,0 @@
[env]
RUSTC_BOOTSTRAP = "1"

View File

@@ -7,87 +7,9 @@ on:
tags: tags:
- v*.*.* - v*.*.*
env:
OCI_REPO: git.huizinga.dev/dreaded_x/${{ gitea.event.repository.name}}
jobs: jobs:
build: build:
name: Build container and manifests uses: dreaded_x/workflows/.gitea/workflows/docker-kubernetes.yaml@ef78704b98c72e4a6b8340f9bff7b085a7bdd95c
runs-on: ubuntu-latest secrets: inherit
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set timestamp and release version
run: |
echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV
git fetch --prune --unshallow --tags --force
echo "RELEASE_VERSION=$(git describe --always --dirty='--modified')" >> $GITHUB_ENV
cat $GITHUB_ENV
- name: Login to registry
uses: docker/login-action@v3
with: with:
registry: git.huizinga.dev webhook_url: ${{ secrets.WEBHOOK_URL }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install kustomize
run: |
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
- name: Setup Flux CLI
uses: https://github.com/fluxcd/flux2/action@main
with:
version: v2.5.0
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.OCI_REPO }}
tags: |
type=edge
type=ref,event=branch
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
- name: Build container
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
sbom: true
provenance: mode=max
tags: ${{ steps.meta.outputs.tags }}
annotations: ${{ steps.meta.outputs.annotations }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
"RELEASE_VERSION=${{ env.RELEASE_VERSION }}"
env:
SOURCE_DATE_EPOCH: ${{ env.TIMESTAMP }}
- name: Generate CRDs
run: |
docker run --rm ${{ env.OCI_REPO }}@${{ steps.build.outputs.imageid }} /crdgen > ./manifests/crds.yaml
- name: Kustomize manifests
run: |
./kustomize build ./manifests | sed "s/\${DIGEST}/${{ steps.build.outputs.digest }}/" > ./manifests.yaml
- name: Push manifests
run: |
flux push artifact oci://${{ env.OCI_REPO }}/manifests:${{ gitea.head_ref || gitea.ref_name }} \
--path="./manifests.yaml" \
--source="$(git config --get remote.origin.url)" \
--revision="$(git rev-parse HEAD)" \
$(echo "${{ steps.meta.outputs.labels }}" | sed -e 's/^/-a /')
flux tag artifact oci://${{ env.OCI_REPO }}/manifests:${{ gitea.head_ref || gitea.ref_name }} \
$(echo "${{ steps.meta.outputs.tags }}" | sed -e 's/^.*:/--tag /')

View File

@@ -2,7 +2,7 @@ fail_fast: true
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v6.0.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
@@ -14,7 +14,7 @@ repos:
- id: check-merge-conflict - id: check-merge-conflict
- repo: https://github.com/crate-ci/typos - repo: https://github.com/crate-ci/typos
rev: v1.31.1 rev: typos-dict-v0.13.13
hooks: hooks:
- id: typos - id: typos
args: ["--force-exclude"] args: ["--force-exclude"]
@@ -72,6 +72,6 @@ repos:
pass_filenames: false pass_filenames: false
- repo: https://github.com/hadolint/hadolint - repo: https://github.com/hadolint/hadolint
rev: v2.12.0 rev: v2.14.0
hooks: hooks:
- id: hadolint - id: hadolint

1570
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,33 +8,38 @@ default-run = "lldap-controller"
members = ["queries"] members = ["queries"]
[workspace.dependencies] [workspace.dependencies]
cynic = "3.10.0" cynic = "3.12.0"
insta = { version = "1.42.2", features = ["yaml"] } cynic-codegen = "3.12.0"
insta = { version = "1.45.0", features = ["yaml"] }
[dependencies] [dependencies]
queries = { path = "./queries" } queries = { path = "./queries" }
anyhow = "1.0.97"
lldap_auth = { git = "https://github.com/lldap/lldap" } lldap_auth = { git = "https://github.com/lldap/lldap" }
# Purposefully kept at 0.8.x for compatibility with lldap
rand = { version = "0.8.5" } rand = { version = "0.8.5" }
serde_json = "1.0.140" serde_json = "1.0.145"
cynic = { workspace = true, features = ["http-reqwest"] } cynic = { workspace = true, features = ["http-reqwest"] }
tokio = { version = "1.44.0", features = ["full"] } tokio = { version = "1.48.0", features = ["full"] }
kube = { version = "0.99.0", features = ["derive", "runtime"] } kube = { version = "2.0.1", features = ["derive", "runtime"] }
k8s-openapi = { version = "0.24.0", features = ["v1_31"] } k8s-openapi = { version = "0.26.1", features = ["v1_34"] }
schemars = { version = "0.8.22", features = ["chrono"] } schemars = { version = "1.1.0", features = ["chrono04"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
futures = "0.3.31" futures = "0.3.31"
tracing-subscriber = { version = "0.3.19", features = ["json", "env-filter"] } tracing-subscriber = { version = "0.3.22", features = ["json", "env-filter"] }
tracing = "0.1.41" tracing = "0.1.44"
thiserror = "2.0.12" thiserror = "2.0.17"
chrono = "0.4.40" chrono = "0.4.42"
passwords = "3.1.16" passwords = "3.1.16"
reqwest = { version = "0.12.14", default-features = false, features = [ reqwest = { version = "0.12.26", default-features = false, features = [
"json", "json",
"rustls-tls", "rustls-tls",
] } ] }
git-version = "0.3.9" git-version = "0.3.9"
color-eyre = "0.6.5"
dotenvy = "0.15.7"
leon = "3.0.2"
async-trait = "0.1.89"
[dev-dependencies] [dev-dependencies]
insta = { workspace = true } insta = { workspace = true }

View File

@@ -1,4 +1,4 @@
FROM rust:1.86 AS base FROM rust:1.92 AS base
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
RUN cargo install cargo-chef --locked --version 0.1.71 && \ RUN cargo install cargo-chef --locked --version 0.1.71 && \
cargo install cargo-auditable --locked --version 0.6.6 cargo install cargo-auditable --locked --version 0.6.6
@@ -15,9 +15,11 @@ RUN cargo chef cook --release --recipe-path recipe.json
COPY . . COPY . .
ARG RELEASE_VERSION ARG RELEASE_VERSION
ENV RELEASE_VERSION=${RELEASE_VERSION} ENV RELEASE_VERSION=${RELEASE_VERSION}
RUN cargo auditable build --release RUN cargo auditable build --release && /app/target/release/crdgen > /crds.yaml
FROM scratch AS manifests
COPY --from=builder /crds.yaml /
FROM gcr.io/distroless/cc-debian12:nonroot AS runtime FROM gcr.io/distroless/cc-debian12:nonroot AS runtime
COPY --from=builder /app/target/release/lldap-controller /lldap-controller COPY --from=builder /app/target/release/lldap-controller /lldap-controller
COPY --from=builder /app/target/release/crdgen /crdgen
CMD ["/lldap-controller"] CMD ["/lldap-controller"]

23
docker-bake.hcl Normal file
View File

@@ -0,0 +1,23 @@
variable "TAG_BASE" {}
variable "RELEASE_VERSION" {}
group "default" {
targets = ["lldap-controller", "manifests"]
}
target "docker-metadata-action" {}
target "lldap-controller" {
inherits = ["docker-metadata-action"]
context = "./"
dockerfile = "Dockerfile"
tags = [for tag in target.docker-metadata-action.tags : "${TAG_BASE}:${tag}"]
target = "runtime"
}
target "manifests" {
context = "./"
dockerfile = "Dockerfile"
target = "manifests"
output = [{ type = "cacheonly" }, "manifests"]
}

View File

@@ -31,6 +31,10 @@ spec:
requests: requests:
cpu: 50m cpu: 50m
memory: 100Mi memory: 100Mi
volumeMounts:
- name: credentials
readOnly: true
mountPath: "/secrets/credentials"
env: env:
- name: RUST_LOG - name: RUST_LOG
value: info,lldap_controller=debug value: info,lldap_controller=debug
@@ -38,8 +42,11 @@ spec:
value: "http://lldap:17170" value: "http://lldap:17170"
- name: LLDAP_USERNAME - name: LLDAP_USERNAME
value: admin value: admin
- name: LLDAP_PASSWORD - name: LLDAP_PASSWORD_FILE
valueFrom: value: /secrets/credentials/lldap-ldap-user-pass
secretKeyRef: - name: LLDAP_BIND_DN
name: lldap-credentials value: uid={username},ou=people,dc=huizinga,dc=dev
key: lldap-ldap-user-pass volumes:
- name: credentials
secret:
secretName: lldap-credentials

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "queries" name = "queries"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
[dependencies] [dependencies]
cynic = { workspace = true } cynic = { workspace = true }
@@ -10,4 +10,4 @@ cynic = { workspace = true }
insta = { workspace = true } insta = { workspace = true }
[build-dependencies] [build-dependencies]
cynic-codegen = "3.10.0" cynic-codegen = { workspace = true }

View File

@@ -1,4 +1,4 @@
[toolchain] [toolchain]
channel = "1.86" channel = "1.92"
profile = "default" profile = "default"
components = ["rust-analyzer"] components = ["rust-analyzer"]

View File

@@ -10,10 +10,16 @@ pub struct Context {
pub lldap_config: LldapConfig, pub lldap_config: LldapConfig,
pub controller_name: String, pub controller_name: String,
pub recorder: Recorder, pub recorder: Recorder,
pub bind_dn_template: String,
} }
impl Context { impl Context {
pub fn new(controller_name: &str, client: kube::Client, lldap_config: LldapConfig) -> Self { pub fn new(
controller_name: &str,
client: kube::Client,
lldap_config: LldapConfig,
bind_dn_template: impl Into<String>,
) -> Self {
let reporter: Reporter = controller_name.into(); let reporter: Reporter = controller_name.into();
let recorder = Recorder::new(client.clone(), reporter); let recorder = Recorder::new(client.clone(), reporter);
@@ -22,6 +28,7 @@ impl Context {
lldap_config, lldap_config,
controller_name: controller_name.into(), controller_name: controller_name.into(),
recorder, recorder,
bind_dn_template: bind_dn_template.into(),
} }
} }
} }

View File

@@ -1,4 +1,3 @@
#![feature(let_chains)]
pub mod context; pub mod context;
pub mod lldap; pub mod lldap;
pub mod resources; pub mod resources;

View File

@@ -1,6 +1,5 @@
use std::time::Duration; use std::time::Duration;
use anyhow::Context;
use cynic::http::{CynicReqwestError, ReqwestExt}; use cynic::http::{CynicReqwestError, ReqwestExt};
use cynic::{GraphQlError, GraphQlResponse, MutationBuilder, QueryBuilder}; use cynic::{GraphQlError, GraphQlResponse, MutationBuilder, QueryBuilder};
use lldap_auth::login::{ClientSimpleLoginRequest, ServerLoginResponse}; use lldap_auth::login::{ClientSimpleLoginRequest, ServerLoginResponse};
@@ -29,16 +28,20 @@ pub enum Error {
Authentication(#[from] AuthenticationError), Authentication(#[from] AuthenticationError),
#[error("GraphQL error: {0}")] #[error("GraphQL error: {0}")]
GraphQl(#[from] GraphQlError), GraphQl(#[from] GraphQlError),
#[error("Missing environment variable: {0}")]
MissingEnvironmentVariable(&'static str),
#[error("Could not read password file: {0}")]
CouldNotReadPasswordFile(#[from] std::io::Error),
} }
pub type Result<T, E = Error> = std::result::Result<T, E>; pub type Result<T, E = Error> = std::result::Result<T, E>;
fn check_graphql_errors<T>(response: GraphQlResponse<T>) -> Result<T> { fn check_graphql_errors<T>(response: GraphQlResponse<T>) -> Result<T> {
if let Some(errors) = &response.errors { if let Some(errors) = &response.errors
if !errors.is_empty() { && !errors.is_empty()
{
Err(errors.first().expect("Should not be empty").clone())?; Err(errors.first().expect("Should not be empty").clone())?;
} }
}
Ok(response Ok(response
.data .data
@@ -53,14 +56,26 @@ pub struct LldapConfig {
} }
impl LldapConfig { impl LldapConfig {
pub fn try_from_env() -> anyhow::Result<Self> { pub fn try_from_env() -> Result<Self> {
let password = std::env::var("LLDAP_PASSWORD_FILE").map_or_else(
|_| {
std::env::var("LLDAP_PASSWORD").map_err(|_| {
Error::MissingEnvironmentVariable("LLDAP_PASSWORD or LLDAP_PASSWORD_FILE")
})
},
|path| {
std::fs::read_to_string(path)
.map(|v| v.trim().into())
.map_err(|err| err.into())
},
)?;
Ok(Self { Ok(Self {
username: std::env::var("LLDAP_USERNAME") username: std::env::var("LLDAP_USERNAME")
.context("Variable 'LLDAP_USERNAME' is not set or invalid")?, .map_err(|_| Error::MissingEnvironmentVariable("LLDAP_USERNAME"))?,
password: std::env::var("LLDAP_PASSWORD") password,
.context("Variable 'LLDAP_PASSWORD' is not set or invalid")?,
url: std::env::var("LLDAP_URL") url: std::env::var("LLDAP_URL")
.context("Variable 'LLDAP_URL' is not set or invalid")?, .map_err(|_| Error::MissingEnvironmentVariable("LLDAP_URL"))?,
}) })
} }

View File

@@ -1,6 +1,8 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use color_eyre::eyre::Context as _;
use dotenvy::dotenv;
use futures::StreamExt; use futures::StreamExt;
use k8s_openapi::api::core::v1::Secret; use k8s_openapi::api::core::v1::Secret;
use kube::runtime::controller::{self, Action}; use kube::runtime::controller::{self, Action};
@@ -35,7 +37,10 @@ async fn log_status<T>(
} }
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
dotenv().ok();
let env_filter = EnvFilter::try_from_default_env() let env_filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("info")) .or_else(|_| EnvFilter::try_new("info"))
.expect("Fallback should be valid"); .expect("Fallback should be valid");
@@ -50,12 +55,15 @@ async fn main() -> anyhow::Result<()> {
info!(version = VERSION, "Starting"); info!(version = VERSION, "Starting");
let bind_dn_template = std::env::var("LLDAP_BIND_DN").wrap_err("LLDAP_BIND_DN is not set")?;
let client = KubeClient::try_default().await?; let client = KubeClient::try_default().await?;
let data = Context::new( let data = Context::new(
"lldap.huizinga.dev", "lldap.huizinga.dev",
client.clone(), client.clone(),
LldapConfig::try_from_env()?, LldapConfig::try_from_env()?,
bind_dn_template,
); );
let secrets = Api::<Secret>::all(client.clone()); let secrets = Api::<Secret>::all(client.clone());

View File

@@ -1,6 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait;
use kube::CustomResource; use kube::CustomResource;
use kube::runtime::controller::Action; use kube::runtime::controller::Action;
use schemars::JsonSchema; use schemars::JsonSchema;
@@ -19,6 +20,7 @@ use crate::context::{Context, ControllerEvents};
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct GroupSpec {} pub struct GroupSpec {}
#[async_trait]
impl Reconcile for Group { impl Reconcile for Group {
async fn reconcile(self: Arc<Self>, ctx: Arc<Context>) -> Result<Action> { async fn reconcile(self: Arc<Self>, ctx: Arc<Context>) -> Result<Action> {
let name = self let name = self

View File

@@ -5,6 +5,7 @@ mod user_attribute;
use core::fmt; use core::fmt;
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait;
use k8s_openapi::{ClusterResourceScope, NamespaceResourceScope}; use k8s_openapi::{ClusterResourceScope, NamespaceResourceScope};
use kube::runtime::controller::Action; use kube::runtime::controller::Action;
use kube::runtime::finalizer; use kube::runtime::finalizer;
@@ -43,7 +44,8 @@ impl From<finalizer::Error<Self>> for Error {
type Result<T, E = Error> = std::result::Result<T, E>; type Result<T, E = Error> = std::result::Result<T, E>;
trait Reconcile { #[async_trait]
pub trait Reconcile {
async fn reconcile(self: Arc<Self>, ctx: Arc<Context>) -> Result<Action>; async fn reconcile(self: Arc<Self>, ctx: Arc<Context>) -> Result<Action>;
async fn cleanup(self: Arc<Self>, ctx: Arc<Context>) -> Result<Action>; async fn cleanup(self: Arc<Self>, ctx: Arc<Context>) -> Result<Action>;

View File

@@ -3,12 +3,14 @@ use std::str::from_utf8;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use k8s_openapi::api::core::v1::Secret; use k8s_openapi::api::core::v1::Secret;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference; use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference;
use kube::api::{ObjectMeta, Patch, PatchParams, PostParams}; use kube::api::{ObjectMeta, Patch, PatchParams, PostParams};
use kube::runtime::controller::Action; use kube::runtime::controller::Action;
use kube::{Api, CustomResource, Resource}; use kube::{Api, CustomResource, Resource};
use leon::{Template, vals};
use passwords::PasswordGenerator; use passwords::PasswordGenerator;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -75,6 +77,7 @@ fn format_username(name: &str, namespace: &str) -> String {
format!("{name}.{namespace}") format!("{name}.{namespace}")
} }
#[async_trait]
impl Reconcile for ServiceUser { impl Reconcile for ServiceUser {
async fn reconcile(self: Arc<Self>, ctx: Arc<Context>) -> Result<Action> { async fn reconcile(self: Arc<Self>, ctx: Arc<Context>) -> Result<Action> {
let name = self let name = self
@@ -113,6 +116,31 @@ impl Reconcile for ServiceUser {
debug!(name, secret_name, "Generating new secret"); debug!(name, secret_name, "Generating new secret");
new_secret(&username, oref) new_secret(&username, oref)
})
.and_modify(|secret| {
let bind_dn_template = match Template::parse(&ctx.bind_dn_template) {
Ok(template) => template,
Err(err) => {
warn!("Invalid bind_dn template: {err}");
return;
}
};
let bind_dn = match bind_dn_template.render(&&vals(|key| match key {
"username" => Some(username.clone().into()),
_ => None,
})) {
Ok(bind_dn) => bind_dn,
Err(err) => {
warn!("Failed to render bind_dn template: {err}");
return;
}
};
secret
.string_data
.get_or_insert_default()
.insert("bind_dn".into(), bind_dn);
}); });
trace!(name, "Committing secret"); trace!(name, "Committing secret");

View File

@@ -1,8 +1,9 @@
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait;
use kube::api::{Patch, PatchParams}; use kube::api::{Patch, PatchParams};
use kube::runtime::controller::Action; use kube::runtime::controller::Action;
use kube::{Api, CELSchema, CustomResource}; use kube::{Api, CustomResource, KubeSchema};
use queries::AttributeType; use queries::AttributeType;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -33,7 +34,7 @@ impl From<Type> for AttributeType {
} }
} }
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, CELSchema)] #[derive(CustomResource, Deserialize, Serialize, Clone, Debug, KubeSchema)]
#[kube( #[kube(
kind = "UserAttribute", kind = "UserAttribute",
group = "lldap.huizinga.dev", group = "lldap.huizinga.dev",
@@ -51,11 +52,11 @@ impl From<Type> for AttributeType {
printcolumn = r#"{"name":"Age", "type":"date", "jsonPath":".metadata.creationTimestamp"}"# printcolumn = r#"{"name":"Age", "type":"date", "jsonPath":".metadata.creationTimestamp"}"#
)] )]
#[kube( #[kube(
rule = Rule::new("self.spec == oldSelf.spec").message("User attributes are immutable"), validation = Rule::new("self.spec == oldSelf.spec").message("User attributes are immutable"),
rule = Rule::new("!self.spec.userEditable || self.spec.userVisible && self.spec.userEditable").message("Editable attribute must also be visible") validation = Rule::new("!self.spec.userEditable || self.spec.userVisible && self.spec.userEditable").message("Editable attribute must also be visible")
)] )]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct UesrAttributeSpec { pub struct UserAttributeSpec {
r#type: Type, r#type: Type,
#[serde(default)] #[serde(default)]
list: bool, list: bool,
@@ -70,6 +71,7 @@ pub struct UserAttributesStatus {
pub synced: bool, pub synced: bool,
} }
#[async_trait]
impl Reconcile for UserAttribute { impl Reconcile for UserAttribute {
async fn reconcile( async fn reconcile(
self: std::sync::Arc<Self>, self: std::sync::Arc<Self>,