Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion .github/workflows/functional.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ jobs:
- 'crates/**'
- 'policy/**'
- 'tools/Dockerfile.skaffold'
- 'tools/Dockerfile.tempest'
- 'tools/tempest/**'

build:
needs: changes
Expand Down Expand Up @@ -414,7 +416,37 @@ jobs:

- name: Run tests
run: |
skaffold verify --profile github-ci --env-file github-oidc.env -a build.artifacts -v debug
skaffold verify --profile github-ci -m keystone --env-file github-oidc.env -a build.artifacts -v debug

- name: Run tempest identity compatibility tests
id: tempest_verify
continue-on-error: true
run: |
skaffold verify --profile github-ci -m tempest -a build.artifacts -v debug 2>&1 | tee tempest-results.log

- name: Summarize tempest identity compatibility results
if: always() && steps.tempest_verify.outcome != 'skipped'
run: |
{
echo "### Tempest identity compatibility (advisory, non-blocking)"
echo
if [ "${{ steps.tempest_verify.outcome }}" = "success" ]; then
echo "All selected tempest identity tests passed against both keystone-py and keystone-rs."
else
echo "Some tempest identity tests failed - see failed test IDs below."
echo
echo '```'
grep -A 200 "FAILED TESTS (target=" tempest-results.log || echo "(unable to parse failures from log)"
echo '```'
fi
} >> "$GITHUB_STEP_SUMMARY"

- name: Upload tempest results log
if: always() && steps.tempest_verify.outcome != 'skipped'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: tempest-identity-results
path: tempest-results.log

- name: Dump K8 pods
if: failure()
Expand Down
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,20 @@ skaffold deploy -a build.artifacts -m keystone
The skaffold config splits into `keystone` and `infra` modules to avoid
redeploying all tracking labels on every change.

**Tempest identity compatibility tests** (advisory, non-blocking): a third
`tempest` module runs the OpenStack tempest identity v3 suite against both
`keystone-rs` and `keystone-py` to track v3 API compatibility gaps. Since
not every v3 operation is implemented by keystone-rs yet, this is run and
reported separately from the main verify suite and never blocks CI:

```console
skaffold verify -m tempest -a build.artifacts -v debug
```

Failing test IDs are printed in each container's log
(`===== FAILED TESTS (target=...) =====`); in CI these are also collected
into a job summary and an uploaded `tempest-identity-results` log artifact.

### OpenStackClient (OSC)

Verify authentication flows with `osc` against a deployed instance. Add
Expand Down
2 changes: 1 addition & 1 deletion crates/api-types/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ conv = ["dep:axum", "dep:openstack-keystone-core-types", "dep:uuid", "builder",
[dependencies]
axum = { workspace = true, features = ["json"], optional = true }
base64.workspace = true
chrono = { workspace = true, default-features = false, features = ["serde"] }
chrono = { workspace = true, default-features = false, features = ["serde", "alloc"] }
derive_builder = { workspace = true, optional = true }
eyre.workspace = true
http = { workspace = true, optional = true }
Expand Down
29 changes: 29 additions & 0 deletions crates/api-types/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,38 @@
//
// SPDX-License-Identifier: Apache-2.0

use chrono::{DateTime, SecondsFormat, Utc};
use secrecy::{ExposeSecret, SecretString};
use serde::Serializer;

/// OpenStack clients (e.g. tempest, python-keystoneclient) parse timestamps
/// with `strptime` formats accepting at most 6 fractional-second digits
/// (`%Y-%m-%dT%H:%M:%S.%fZ`). `DateTime<Utc>`'s default serde impl emits full
/// nanosecond precision, which those clients cannot parse. Truncate to
/// microseconds on the wire to match the format Keystone has always produced.
pub(crate) fn serialize_datetime_micros<S>(
dt: &DateTime<Utc>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&dt.to_rfc3339_opts(SecondsFormat::Micros, true))
}

pub(crate) fn serialize_optional_datetime_micros<S>(
dt: &Option<DateTime<Utc>>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match dt {
Some(dt) => serializer.serialize_some(&dt.to_rfc3339_opts(SecondsFormat::Micros, true)),
None => serializer.serialize_none(),
}
}

pub(crate) fn serialize_secret_string<S>(
secret: &SecretString,
serializer: S,
Expand Down
5 changes: 5 additions & 0 deletions crates/api-types/src/error_conv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,11 @@ impl From<ResourceProviderError> for KeystoneApiError {
resource: "domain".into(),
identifier: x,
},
ResourceProviderError::ProjectNotFound(x) => Self::NotFound {
resource: "project".into(),
identifier: x,
},
ResourceProviderError::InvalidProjectDomain(x) => Self::BadRequest(x),
other => Self::InternalError(other.to_string()),
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/api-types/src/trust/trust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub struct TokenTrustRepr {
/// redelegated trust.
#[cfg_attr(feature = "builder", builder(default))]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(serialize_with = "crate::common::serialize_optional_datetime_micros")]
pub expires_at: Option<DateTime<Utc>>,

/// The ID of the trust.
Expand Down
3 changes: 3 additions & 0 deletions crates/api-types/src/v3/auth/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ pub struct Token {
pub methods: Vec<String>,

/// The date and time when the token expires.
#[serde(serialize_with = "crate::common::serialize_datetime_micros")]
pub expires_at: DateTime<Utc>,

/// The date and time when the token was issued.
#[serde(serialize_with = "crate::common::serialize_datetime_micros")]
pub issued_at: DateTime<Utc>,

// # Subject
Expand Down Expand Up @@ -357,6 +359,7 @@ pub struct User {
pub domain: Domain,
/// User password expiry date.
#[cfg_attr(feature = "builder", builder(default))]
#[serde(serialize_with = "crate::common::serialize_optional_datetime_micros")]
pub password_expires_at: Option<DateTime<Utc>>,
}

Expand Down
34 changes: 32 additions & 2 deletions crates/api-types/src/v3/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ pub struct ProjectShort {
pub struct Project {
/// The description of the project.
#[cfg_attr(feature = "builder", builder(default))]
#[serde(skip_serializing_if = "Option::is_none")]
//#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))]
pub description: Option<String>,

Expand Down Expand Up @@ -122,12 +122,18 @@ pub struct ProjectCreate {
pub description: Option<String>,

/// The ID of the domain for the project.
///
/// If not specified, the domain is resolved from `parent_id`'s domain,
/// or falls back to the domain the caller's token is scoped to.
#[cfg_attr(feature = "builder", builder(default))]
#[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))]
pub domain_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_id: Option<String>,

/// If set to true, project is enabled. If set to false, project is
/// disabled. The defaults is `true`.
#[cfg_attr(feature = "builder", builder(default = "crate::default_true()"))]
#[serde(default = "crate::default_true")]
pub enabled: bool,

/// Additional project properties.
Expand All @@ -144,6 +150,7 @@ pub struct ProjectCreate {
/// that contains only resources. Default is false. You cannot update this
/// parameter after you create the project.
#[cfg_attr(feature = "builder", builder(default))]
#[serde(default)]
pub is_domain: bool,

/// The name of the project, which must be unique within the owning domain.
Expand Down Expand Up @@ -176,6 +183,10 @@ impl ProjectCreateBuilder {
if self.parent_id.is_some() && self.is_domain.is_some_and(|x| x) {
return Err("project cannot specify `parent_id` when `is_domain` is true".to_string());
}
if self.domain_id.as_ref().is_some_and(|x| x.is_some()) && self.is_domain.is_some_and(|x| x)
{
return Err("project cannot specify `domain_id` when `is_domain` is true".to_string());
}
Ok(())
}
}
Expand Down Expand Up @@ -246,6 +257,13 @@ mod tests {
assert!(sot.enabled, "enabled defaults to true");
assert!(!sot.is_domain, "is_domain defaults to false");
assert!(sot.parent_id.is_none());

let sot_no_domain = ProjectCreateBuilder::default()
.name("name")
.build()
.unwrap();
assert!(sot_no_domain.domain_id.is_none(), "domain_id is optional");

if let Err(BuilderError::Validation(..)) = ProjectCreateBuilder::default()
.name("name")
.domain_id("did")
Expand All @@ -259,6 +277,18 @@ mod tests {
"an error should be raised not allowing to set parent_id with the is_domain=true"
);
}

if let Err(BuilderError::Validation(..)) = ProjectCreateBuilder::default()
.name("name")
.domain_id("did")
.is_domain(true)
.build()
{
} else {
panic!(
"an error should be raised not allowing to set domain_id with the is_domain=true"
);
}
}

#[cfg(feature = "builder")]
Expand Down
2 changes: 2 additions & 0 deletions crates/api-types/src/v3/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ pub struct User {
/// The date and time when the password expires. The time zone is UTC.
#[cfg_attr(feature = "builder", builder(default))]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(serialize_with = "crate::common::serialize_optional_datetime_micros")]
pub password_expires_at: Option<DateTime<Utc>>,
}

Expand Down Expand Up @@ -138,6 +139,7 @@ pub struct UserCreate {
/// If the user is enabled, this value is true. If the user is disabled,
/// this value is false.
#[cfg_attr(feature = "builder", builder(default))]
#[serde(default = "crate::default_true")]
pub enabled: bool,

/// Additional user properties.
Expand Down
6 changes: 6 additions & 0 deletions crates/core-types/src/resource/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ pub enum ResourceProviderError {
#[error("domain {0} not found")]
DomainNotFound(String),

/// Invalid `domain_id`/`is_domain`/`parent_id` combination on project
/// create (e.g. `domain_id` set while `is_domain` is true, or `domain_id`
/// not matching the parent project's domain).
#[error("invalid project domain: {0}")]
InvalidProjectDomain(String),

/// OAuth2 signing key provider error, surfaced when provisioning a
/// domain's initial signing keypair synchronously on domain creation
/// (ADR 0026 §3, "Domain creation").
Expand Down
3 changes: 2 additions & 1 deletion crates/core-types/src/resource/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,9 @@ pub struct ProjectCreate {
pub description: Option<String>,

/// The ID of the domain for the project.
#[builder(default)]
#[validate(length(min = 1, max = 64))]
pub domain_id: String,
pub domain_id: Option<String>,

/// If set to true, project is enabled. If set to false, project is
/// disabled.
Expand Down
5 changes: 2 additions & 3 deletions crates/core/src/oauth2_key/provider_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,8 @@ pub trait Oauth2KeyApi: Send + Sync {
///
/// # Errors
/// * [`Oauth2KeyProviderError::LocalEmergencyBypassNotAllowed`] if the
/// node's quorum-bypass guardrail refuses the request (bypass
/// disabled, quorum currently reachable, or grace period not yet
/// elapsed).
/// node's quorum-bypass guardrail refuses the request (bypass disabled,
/// quorum currently reachable, or grace period not yet elapsed).
/// * [`Oauth2KeyProviderError::LocalEmergencyAlreadyStaged`] if a
/// non-revoked local candidate already exists for this domain on this
/// node.
Expand Down
Loading
Loading