diff --git a/.github/workflows/functional.yml b/.github/workflows/functional.yml index c11d2119e..6a521c9f3 100644 --- a/.github/workflows/functional.yml +++ b/.github/workflows/functional.yml @@ -52,6 +52,8 @@ jobs: - 'crates/**' - 'policy/**' - 'tools/Dockerfile.skaffold' + - 'tools/Dockerfile.tempest' + - 'tools/tempest/**' build: needs: changes @@ -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() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ae2f9565..9151b343c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/crates/api-types/Cargo.toml b/crates/api-types/Cargo.toml index 2eea30458..da822e243 100644 --- a/crates/api-types/Cargo.toml +++ b/crates/api-types/Cargo.toml @@ -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 } diff --git a/crates/api-types/src/common.rs b/crates/api-types/src/common.rs index fdc8be9c5..1f4587dcf 100644 --- a/crates/api-types/src/common.rs +++ b/crates/api-types/src/common.rs @@ -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`'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( + dt: &DateTime, + serializer: S, +) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&dt.to_rfc3339_opts(SecondsFormat::Micros, true)) +} + +pub(crate) fn serialize_optional_datetime_micros( + dt: &Option>, + serializer: S, +) -> Result +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( secret: &SecretString, serializer: S, diff --git a/crates/api-types/src/error_conv.rs b/crates/api-types/src/error_conv.rs index 6bb5de044..317738566 100644 --- a/crates/api-types/src/error_conv.rs +++ b/crates/api-types/src/error_conv.rs @@ -304,6 +304,11 @@ impl From 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()), } } diff --git a/crates/api-types/src/trust/trust.rs b/crates/api-types/src/trust/trust.rs index c67da83ce..9b374aac7 100644 --- a/crates/api-types/src/trust/trust.rs +++ b/crates/api-types/src/trust/trust.rs @@ -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>, /// The ID of the trust. diff --git a/crates/api-types/src/v3/auth/token.rs b/crates/api-types/src/v3/auth/token.rs index c005ea2c9..f233aba75 100644 --- a/crates/api-types/src/v3/auth/token.rs +++ b/crates/api-types/src/v3/auth/token.rs @@ -61,9 +61,11 @@ pub struct Token { pub methods: Vec, /// The date and time when the token expires. + #[serde(serialize_with = "crate::common::serialize_datetime_micros")] pub expires_at: DateTime, /// The date and time when the token was issued. + #[serde(serialize_with = "crate::common::serialize_datetime_micros")] pub issued_at: DateTime, // # Subject @@ -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>, } diff --git a/crates/api-types/src/v3/project.rs b/crates/api-types/src/v3/project.rs index de919d877..8ac62dac5 100644 --- a/crates/api-types/src/v3/project.rs +++ b/crates/api-types/src/v3/project.rs @@ -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, @@ -122,12 +122,18 @@ pub struct ProjectCreate { pub description: Option, /// 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, /// 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. @@ -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. @@ -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(()) } } @@ -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") @@ -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")] diff --git a/crates/api-types/src/v3/user.rs b/crates/api-types/src/v3/user.rs index 74370b5c5..114d948d7 100644 --- a/crates/api-types/src/v3/user.rs +++ b/crates/api-types/src/v3/user.rs @@ -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>, } @@ -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. diff --git a/crates/core-types/src/resource/error.rs b/crates/core-types/src/resource/error.rs index 88bc370ec..03cac2550 100644 --- a/crates/core-types/src/resource/error.rs +++ b/crates/core-types/src/resource/error.rs @@ -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"). diff --git a/crates/core-types/src/resource/project.rs b/crates/core-types/src/resource/project.rs index 76d125f20..8f1edc58f 100644 --- a/crates/core-types/src/resource/project.rs +++ b/crates/core-types/src/resource/project.rs @@ -77,8 +77,9 @@ pub struct ProjectCreate { pub description: Option, /// The ID of the domain for the project. + #[builder(default)] #[validate(length(min = 1, max = 64))] - pub domain_id: String, + pub domain_id: Option, /// If set to true, project is enabled. If set to false, project is /// disabled. diff --git a/crates/core/src/oauth2_key/provider_api.rs b/crates/core/src/oauth2_key/provider_api.rs index faea8843a..1ec22c670 100644 --- a/crates/core/src/oauth2_key/provider_api.rs +++ b/crates/core/src/oauth2_key/provider_api.rs @@ -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. diff --git a/crates/core/src/resource/service.rs b/crates/core/src/resource/service.rs index 9000f037a..65698e6cc 100644 --- a/crates/core/src/resource/service.rs +++ b/crates/core/src/resource/service.rs @@ -18,6 +18,7 @@ use uuid::Uuid; use validator::Validate; use openstack_keystone_config::Config; +use openstack_keystone_core_types::auth::ScopeInfo; use openstack_keystone_core_types::events::{Event, EventPayload, Operation}; use openstack_keystone_core_types::resource::*; @@ -50,6 +51,85 @@ impl ResourceService { .clone(); Ok(Self { backend_driver }) } + + /// Resolve the `domain_id` a new project should be created in. + /// + /// Mirrors python-keystone's project-create semantics: + /// - `is_domain=true`: the project is its own domain, `domain_id` must not + /// be given. + /// - `domain_id` and `parent_id` both given: they must agree (parent's + /// domain). + /// - only `parent_id` given: inherit the parent's domain. + /// - only `domain_id` given: use it as-is. + /// - neither given: fall back to the domain the caller's token is scoped + /// to. + async fn resolve_project_domain_id<'a>( + &self, + ctx: &ExecutionContext<'a>, + new_project: &ProjectCreate, + project_id: &str, + ) -> Result { + if new_project.is_domain { + return if new_project.domain_id.is_some() { + Err(ResourceProviderError::InvalidProjectDomain( + "domain_id must not be specified when is_domain is true".into(), + )) + } else { + Ok(project_id.to_string()) + }; + } + + match (&new_project.domain_id, &new_project.parent_id) { + (Some(domain_id), Some(parent_id)) => { + let parent_domain_id = self.parent_domain_id(ctx, parent_id).await?; + if &parent_domain_id != domain_id { + return Err(ResourceProviderError::InvalidProjectDomain(format!( + "domain_id {domain_id} does not match parent project's domain {parent_domain_id}" + ))); + } + Ok(domain_id.clone()) + } + (Some(domain_id), None) => Ok(domain_id.clone()), + (None, Some(parent_id)) => self.parent_domain_id(ctx, parent_id).await, + (None, None) => scope_domain_id(ctx).ok_or_else(|| { + ResourceProviderError::InvalidProjectDomain( + "domain_id could not be determined: not specified, no parent_id, and no scoped token domain".into(), + ) + }), + } + } + + /// Resolve the `domain_id` that `parent_id` belongs to. + /// + /// `parent_id` may be a regular project or a domain's root (`is_domain`) + /// project -- the latter is how a project is created directly under a + /// domain, matching python-keystone where domains are themselves + /// projects. + async fn parent_domain_id<'a>( + &self, + ctx: &ExecutionContext<'a>, + parent_id: &str, + ) -> Result { + if let Some(parent) = self.get_project(ctx, parent_id).await? { + return Ok(parent.domain_id); + } + if self.get_domain(ctx, parent_id).await?.is_some() { + return Ok(parent_id.to_string()); + } + Err(ResourceProviderError::ProjectNotFound( + parent_id.to_string(), + )) + } +} + +/// Extracts the domain ID the caller's token is scoped to, if any. +fn scope_domain_id(ctx: &ExecutionContext<'_>) -> Option { + let scope = &ctx.ctx()?.authorization()?.scope; + match scope { + ScopeInfo::Domain(domain) => Some(domain.id.clone()), + ScopeInfo::Project { project_domain, .. } => Some(project_domain.id.clone()), + _ => None, + } } #[async_trait] @@ -174,6 +254,10 @@ impl ResourceApi for ResourceService { new_project.id = Some(pid.clone()); pid }; + new_project.domain_id = Some( + self.resolve_project_domain_id(ctx, &new_project, &project_id) + .await?, + ); new_project.validate()?; let project = if let Some(vsc) = ctx.ctx() { let backend_driver = &self.backend_driver; @@ -454,8 +538,8 @@ mod tests { use crate::resource::backend::MockResourceBackend; use crate::tests::get_mocked_state; use openstack_keystone_core_types::auth::{ - AuthenticationContext, IdentityInfo, PrincipalInfo, SecurityContext, - UserIdentityInfoBuilder, + AuthenticationContext, AuthzInfoBuilder, IdentityInfo, PrincipalInfo, ScopeInfo, + SecurityContext, UserIdentityInfoBuilder, }; use openstack_keystone_core_types::resource::DomainBuilder; @@ -473,6 +557,22 @@ mod tests { ValidatedSecurityContext::test_new(sc) } + fn make_vsc_scoped(scope: ScopeInfo) -> ValidatedSecurityContext { + let user = UserIdentityInfoBuilder::default() + .user_id("test-user-id".to_string()) + .build() + .unwrap(); + let authz = AuthzInfoBuilder::default().scope(scope).build().unwrap(); + let sc = SecurityContext::test_build() + .authentication_context(AuthenticationContext::Password) + .principal(PrincipalInfo { + identity: IdentityInfo::User(user), + }) + .authorization(authz) + .build(); + ValidatedSecurityContext::test_new(sc) + } + struct CountingHook { count: Arc, } @@ -598,4 +698,211 @@ mod tests { .is_ok() ); } + + fn base_project_create() -> ProjectCreate { + ProjectCreate { + description: None, + domain_id: None, + enabled: true, + extra: Default::default(), + id: None, + is_domain: false, + name: "pname".into(), + parent_id: None, + } + } + + fn make_domain(id: &str) -> openstack_keystone_core_types::resource::Domain { + DomainBuilder::default() + .id(id) + .name(format!("{id}-name")) + .enabled(true) + .build() + .unwrap() + } + + fn make_project(id: &str, domain_id: &str) -> Project { + ProjectBuilder::default() + .id(id) + .domain_id(domain_id) + .name(format!("{id}-name")) + .enabled(true) + .build() + .unwrap() + } + + #[tokio::test] + async fn test_resolve_project_domain_id_is_domain_self() { + let state = get_mocked_state(None, Some(Provider::mocked_builder())).await; + let provider = ResourceService { + backend_driver: Arc::new(MockResourceBackend::default()), + }; + let mut project = base_project_create(); + project.is_domain = true; + + let resolved = provider + .resolve_project_domain_id(&ExecutionContext::internal(&state), &project, "genid") + .await + .unwrap(); + assert_eq!(resolved, "genid"); + } + + #[tokio::test] + async fn test_resolve_project_domain_id_is_domain_rejects_domain_id() { + let state = get_mocked_state(None, Some(Provider::mocked_builder())).await; + let provider = ResourceService { + backend_driver: Arc::new(MockResourceBackend::default()), + }; + let mut project = base_project_create(); + project.is_domain = true; + project.domain_id = Some("did".into()); + + let err = provider + .resolve_project_domain_id(&ExecutionContext::internal(&state), &project, "genid") + .await + .unwrap_err(); + assert!(matches!( + err, + ResourceProviderError::InvalidProjectDomain(_) + )); + } + + #[tokio::test] + async fn test_resolve_project_domain_id_explicit() { + let state = get_mocked_state(None, Some(Provider::mocked_builder())).await; + let provider = ResourceService { + backend_driver: Arc::new(MockResourceBackend::default()), + }; + let mut project = base_project_create(); + project.domain_id = Some("did".into()); + + let resolved = provider + .resolve_project_domain_id(&ExecutionContext::internal(&state), &project, "genid") + .await + .unwrap(); + assert_eq!(resolved, "did"); + } + + #[tokio::test] + async fn test_resolve_project_domain_id_inherits_from_parent() { + let state = get_mocked_state(None, Some(Provider::mocked_builder())).await; + let mut backend = MockResourceBackend::default(); + backend + .expect_get_project() + .withf(|_, id: &'_ str| id == "pid") + .returning(|_, _| Ok(Some(make_project("pid", "parent-did")))); + let provider = ResourceService { + backend_driver: Arc::new(backend), + }; + let mut project = base_project_create(); + project.parent_id = Some("pid".into()); + + let resolved = provider + .resolve_project_domain_id(&ExecutionContext::internal(&state), &project, "genid") + .await + .unwrap(); + assert_eq!(resolved, "parent-did"); + } + + #[tokio::test] + async fn test_resolve_project_domain_id_matches_parent() { + let state = get_mocked_state(None, Some(Provider::mocked_builder())).await; + let mut backend = MockResourceBackend::default(); + backend + .expect_get_project() + .withf(|_, id: &'_ str| id == "pid") + .returning(|_, _| Ok(Some(make_project("pid", "did")))); + let provider = ResourceService { + backend_driver: Arc::new(backend), + }; + let mut project = base_project_create(); + project.parent_id = Some("pid".into()); + project.domain_id = Some("did".into()); + + let resolved = provider + .resolve_project_domain_id(&ExecutionContext::internal(&state), &project, "genid") + .await + .unwrap(); + assert_eq!(resolved, "did"); + } + + #[tokio::test] + async fn test_resolve_project_domain_id_mismatch_with_parent_errors() { + let state = get_mocked_state(None, Some(Provider::mocked_builder())).await; + let mut backend = MockResourceBackend::default(); + backend + .expect_get_project() + .withf(|_, id: &'_ str| id == "pid") + .returning(|_, _| Ok(Some(make_project("pid", "parent-did")))); + let provider = ResourceService { + backend_driver: Arc::new(backend), + }; + let mut project = base_project_create(); + project.parent_id = Some("pid".into()); + project.domain_id = Some("other-did".into()); + + let err = provider + .resolve_project_domain_id(&ExecutionContext::internal(&state), &project, "genid") + .await + .unwrap_err(); + assert!(matches!( + err, + ResourceProviderError::InvalidProjectDomain(_) + )); + } + + #[tokio::test] + async fn test_resolve_project_domain_id_falls_back_to_domain_scope() { + let state = get_mocked_state(None, Some(Provider::mocked_builder())).await; + let provider = ResourceService { + backend_driver: Arc::new(MockResourceBackend::default()), + }; + let vsc = make_vsc_scoped(ScopeInfo::Domain(make_domain("scoped-did"))); + let ctx = ExecutionContext::from_auth(&state, &vsc); + let project = base_project_create(); + + let resolved = provider + .resolve_project_domain_id(&ctx, &project, "genid") + .await + .unwrap(); + assert_eq!(resolved, "scoped-did"); + } + + #[tokio::test] + async fn test_resolve_project_domain_id_falls_back_to_project_scope_domain() { + let state = get_mocked_state(None, Some(Provider::mocked_builder())).await; + let provider = ResourceService { + backend_driver: Arc::new(MockResourceBackend::default()), + }; + let vsc = make_vsc_scoped(ScopeInfo::Project { + project: make_project("scope-pid", "scoped-did"), + project_domain: make_domain("scoped-did"), + }); + let ctx = ExecutionContext::from_auth(&state, &vsc); + let project = base_project_create(); + + let resolved = provider + .resolve_project_domain_id(&ctx, &project, "genid") + .await + .unwrap(); + assert_eq!(resolved, "scoped-did"); + } + + #[tokio::test] + async fn test_resolve_project_domain_id_no_domain_no_scope_errors() { + let state = get_mocked_state(None, Some(Provider::mocked_builder())).await; + let provider = ResourceService { + backend_driver: Arc::new(MockResourceBackend::default()), + }; + let project = base_project_create(); + + let err = provider + .resolve_project_domain_id(&ExecutionContext::internal(&state), &project, "genid") + .await + .unwrap_err(); + assert!(matches!( + err, + ResourceProviderError::InvalidProjectDomain(_) + )); + } } diff --git a/crates/keystone/src/api/v3/auth/token/create.rs b/crates/keystone/src/api/v3/auth/token/create.rs index 41c0132b0..e27e67bdb 100644 --- a/crates/keystone/src/api/v3/auth/token/create.rs +++ b/crates/keystone/src/api/v3/auth/token/create.rs @@ -145,7 +145,7 @@ async fn create_inner( api_token.token.catalog = Some(catalog); } let response = ( - StatusCode::OK, + StatusCode::CREATED, [( "X-Subject-Token", state @@ -421,7 +421,7 @@ mod tests { .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::CREATED); let body = response.into_body().collect().await.unwrap().to_bytes(); let res: TokenResponse = serde_json::from_slice(&body).unwrap(); assert_eq!(vec!["password"], res.token.methods); @@ -1480,7 +1480,7 @@ mod auth_plugin_http_tests { let body = response.into_body().collect().await.unwrap().to_bytes(); assert_eq!( status, - StatusCode::OK, + StatusCode::CREATED, "body: {}", String::from_utf8_lossy(&body) ); @@ -1572,7 +1572,7 @@ mod auth_plugin_http_tests { let body = response.into_body().collect().await.unwrap().to_bytes(); assert_eq!( status, - StatusCode::OK, + StatusCode::CREATED, "body: {}", String::from_utf8_lossy(&body) ); diff --git a/crates/keystone/src/api/v3/project/create.rs b/crates/keystone/src/api/v3/project/create.rs index 97f78e216..516e1aa50 100644 --- a/crates/keystone/src/api/v3/project/create.rs +++ b/crates/keystone/src/api/v3/project/create.rs @@ -21,9 +21,9 @@ use serde_json::json; use validator::Validate; use super::types::{ProjectCreateRequest, ProjectResponse}; -use crate::api::auth::Auth; use crate::api::error::KeystoneApiError; use crate::keystone::ServiceState; +use crate::{api::auth::Auth, common::TracedJson}; use openstack_keystone_core::auth::ExecutionContext; /// Create project. @@ -44,7 +44,7 @@ use openstack_keystone_core::auth::ExecutionContext; pub(super) async fn create( Auth(user_auth): Auth, State(state): State, - Json(payload): Json, + TracedJson(payload): TracedJson, ) -> Result { // Validate the request payload.validate()?; diff --git a/crates/local-emergency-store/src/guardrail.rs b/crates/local-emergency-store/src/guardrail.rs index 3de548a57..646f50af3 100644 --- a/crates/local-emergency-store/src/guardrail.rs +++ b/crates/local-emergency-store/src/guardrail.rs @@ -32,7 +32,8 @@ pub struct GuardrailConfig { /// bypass is refused unconditionally. pub enabled: bool, /// How long the leader must have been unknown before the bypass - /// unlocks, in seconds (`[local_emergency].leaderless_grace_period_seconds`). + /// unlocks, in seconds + /// (`[local_emergency].leaderless_grace_period_seconds`). pub leaderless_grace_period_seconds: u64, } @@ -44,8 +45,8 @@ pub struct GuardrailConfig { /// quorum is healthy and the bypass must be refused regardless of grace /// period /// - `leaderless_since`: when the leader was first observed to be unknown; -/// `None` means it just became unknown this instant (grace period starts -/// now, so the request is refused) +/// `None` means it just became unknown this instant (grace period starts now, +/// so the request is refused) /// - `now`: caller-supplied clock, so tests don't need to sleep in wall time /// /// # Returns diff --git a/crates/local-emergency-store/src/lib.rs b/crates/local-emergency-store/src/lib.rs index 83d3c6e92..344a88736 100644 --- a/crates/local-emergency-store/src/lib.rs +++ b/crates/local-emergency-store/src/lib.rs @@ -23,11 +23,10 @@ //! //! This crate provides only the shared, subsystem-agnostic pieces: //! -//! - [`LocalEmergencyStore`] — the storage trait candidates are written -//! through +//! - [`LocalEmergencyStore`] — the storage trait candidates are written through //! - [`key`] — the `_local:::emergency:` -//! namespace key-builders, so every backend and every subsystem agrees on -//! the same layout +//! namespace key-builders, so every backend and every subsystem agrees on the +//! same layout //! - [`EmergencyCandidate`] — the record shape stored per rotation attempt //! - [`Subsystem`] — the two ADR 0028 instantiations //! diff --git a/crates/resource-driver-sql/src/project.rs b/crates/resource-driver-sql/src/project.rs index ca3bed080..65ced4e89 100644 --- a/crates/resource-driver-sql/src/project.rs +++ b/crates/resource-driver-sql/src/project.rs @@ -74,7 +74,11 @@ impl TryFrom for db_project::ActiveModel { fn try_from(value: ProjectCreate) -> Result { Ok(Self { description: value.description.map(Set).unwrap_or(NotSet).into(), - domain_id: Set(value.domain_id), + domain_id: Set(value.domain_id.ok_or_else(|| { + ResourceProviderError::Driver( + "domain_id must be resolved before persisting a project".into(), + ) + })?), enabled: Set(Some(value.enabled)), extra: Set(Some(serde_json::to_string(&value.extra)?)), id: Set(value.id.unwrap_or(Uuid::new_v4().simple().to_string())), diff --git a/crates/resource-driver-sql/src/project/create.rs b/crates/resource-driver-sql/src/project/create.rs index a826e514c..a3fd6a7ac 100644 --- a/crates/resource-driver-sql/src/project/create.rs +++ b/crates/resource-driver-sql/src/project/create.rs @@ -56,7 +56,7 @@ mod tests { let req = ProjectCreate { description: Some("description".into()), - domain_id: "foo_domain".into(), + domain_id: Some("foo_domain".into()), enabled: true, extra: std::collections::HashMap::new(), id: Some("1".into()), diff --git a/crates/storage/src/local_emergency.rs b/crates/storage/src/local_emergency.rs index 88df47100..6c469cd30 100644 --- a/crates/storage/src/local_emergency.rs +++ b/crates/storage/src/local_emergency.rs @@ -16,10 +16,11 @@ //! 2). //! //! Wraps a dedicated `local_emergency` Fjall keyspace, opened directly off -//! the same [`Database`] handle [`crate::store::state_machine::FjallStateMachine`] -//! uses — but never touched by Raft's `apply()`, and never written through -//! [`crate::StorageApi`]. That is the whole point of the quorum-bypass path: -//! writes here succeed even when the node cannot reach Raft quorum. +//! the same [`Database`] handle +//! [`crate::store::state_machine::FjallStateMachine`] uses — but never touched +//! by Raft's `apply()`, and never written through [`crate::StorageApi`]. That +//! is the whole point of the quorum-bypass path: writes here succeed even when +//! the node cannot reach Raft quorum. //! //! The quorum-bypass guardrail itself ([`LeaderlessTracker`], //! `GuardrailConfig`, `is_quorum_bypass_allowed`) lives in the lightweight diff --git a/crates/webauthn/src/api/auth/finish.rs b/crates/webauthn/src/api/auth/finish.rs index e033055d1..ea4322d2d 100644 --- a/crates/webauthn/src/api/auth/finish.rs +++ b/crates/webauthn/src/api/auth/finish.rs @@ -38,7 +38,7 @@ use openstack_keystone_core::auth::*; operation_id = "/auth/passkey/finish:post", request_body = PasskeyAuthenticationFinishRequest, responses( - (status = OK, description = "Authentication Token object", body = TokenResponse, + (status = CREATED, description = "Authentication Token object", body = TokenResponse, headers( ("x-subject-token" = String, description = "Keystone token"), ) @@ -181,7 +181,7 @@ pub async fn finish( token: TokenBuilder::try_from(&vsc)?.build()?, }; Ok(( - StatusCode::OK, + StatusCode::CREATED, [( "X-Subject-Token", state diff --git a/crates/webauthn/src/api/auth/start.rs b/crates/webauthn/src/api/auth/start.rs index 992d0881d..2a7c2253a 100644 --- a/crates/webauthn/src/api/auth/start.rs +++ b/crates/webauthn/src/api/auth/start.rs @@ -13,7 +13,7 @@ // SPDX-License-Identifier: Apache-2.0 //! # Start passkey authentication process -use axum::{Json, extract::State, response::IntoResponse}; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use validator::Validate; use openstack_keystone_api_types::error::KeystoneApiError; @@ -32,7 +32,7 @@ use crate::api::types::{CombinedExtensionState, auth::*}; path = "/start", operation_id = "/auth/passkey/start:post", responses( - (status = OK, description = "Challenge that must be signed with any of the user passkeys", body = PasskeyAuthenticationStartResponse), + (status = CREATED, description = "Challenge that must be signed with any of the user passkeys", body = PasskeyAuthenticationStartResponse), (status = 500, description = "Internal error", example = json!(KeystoneApiError::InternalError(String::from("id = 1")))) ), tags = ["passkey", "auth"] @@ -105,5 +105,5 @@ pub async fn start( } }; - Ok(res) + Ok((StatusCode::CREATED, res)) } diff --git a/crates/webauthn/src/api/register/start.rs b/crates/webauthn/src/api/register/start.rs index f1b3314ac..144f9ca69 100644 --- a/crates/webauthn/src/api/register/start.rs +++ b/crates/webauthn/src/api/register/start.rs @@ -15,6 +15,7 @@ use axum::{ Json, extract::{Path, State}, + http::StatusCode, response::IntoResponse, }; use uuid::Uuid; @@ -113,5 +114,5 @@ pub(super) async fn start( } }; - Ok(res) + Ok((StatusCode::CREATED, res)) } diff --git a/crates/webauthn/tests/api.rs b/crates/webauthn/tests/api.rs index eb417e52c..8aaba52b9 100644 --- a/crates/webauthn/tests/api.rs +++ b/crates/webauthn/tests/api.rs @@ -349,7 +349,7 @@ async fn test_webauthn_auth_start_unknown_user_returns_decoys() -> Result<()> { })?) .send() .await?; - assert_eq!(rsp.status(), 200, "start must succeed for unknown users"); + assert_eq!(rsp.status(), 201, "start must succeed for unknown users"); Ok(rsp.json::().await?) }; diff --git a/doc/src/developer.md b/doc/src/developer.md index 4d781af44..ab7fa4bb0 100644 --- a/doc/src/developer.md +++ b/doc/src/developer.md @@ -68,13 +68,32 @@ skaffold deploy -a build.artifacts skaffold verify -a build.artifacts ``` -The skaffold config is split into 2 modules: `keystone` and `infra` allowing -quicker redeployment of keystone only without touching the -keycloak/dex/selenium and co (`skaffold deploy -a build.artifacts -m +The skaffold config is split into 3 modules: `keystone`, `infra` and +`tempest`, allowing quicker redeployment of keystone only without touching +the keycloak/dex/selenium and co (`skaffold deploy -a build.artifacts -m keystone`). This is required to workaround a "feature" of skaffold attaching tracking labels to all resources created from local manifests (including helm files). +### Tempest identity compatibility tests + +The `tempest` module runs the OpenStack tempest identity v3 test suite +against both `keystone-rs` and `keystone-py`, deployed side by side by the +`keystone` module. Since not every v3 operation is implemented by +keystone-rs yet, these results are advisory rather than a merge gate: the +module is run and reported on separately from the main verify suite so it +never blocks CI. + +```console +skaffold verify -m tempest -a build.artifacts -v debug +``` + +Failing test IDs are printed per target in the pod logs +(`===== FAILED TESTS (target=...) =====`), and are used to track API +compatibility gaps and catch breaking changes over time. In CI these are +also collected into a job summary and an uploaded `tempest-identity-results` +log artifact. + ## OpenStackClient (OSC) Deploying Keystone in the Kubernetes makes it also possible to verify the diff --git a/skaffold.yaml b/skaffold.yaml index e4b05a087..e0395e06a 100644 --- a/skaffold.yaml +++ b/skaffold.yaml @@ -136,6 +136,15 @@ deploy: sleep "$backoff" done echo "OAuth2 signing key ensured for domain default" + - host: + # Extend catalog with identity-rs catalog entry + command: + - sh + - -c + - | + kubectl exec keystone-rs-0 -c keystone -- keystone-manage catalog service create --name keystone-rs --type identity-rs --enabled || true + kubectl exec keystone-rs-0 -c keystone -- keystone-manage catalog endpoint create --service-name keystone-rs --interface public --url "http://keystone-rs.local" --enabled || true + kubectl exec keystone-rs-0 -c keystone -- keystone-manage catalog endpoint create --service-name keystone-rs --interface internal --url "http://keystone-rs.default.svc.cluster.local:8080" --enabled || true verify: - &api_test_v3 @@ -315,3 +324,100 @@ profiles: useDockerCLI: true concurrency: 0 push: true +--- +apiVersion: skaffold/v4beta13 +kind: Config +metadata: + name: tempest +# Tempest identity compatibility tests. Run independently of the `keystone` +# module's verify tests (`skaffold verify -m tempest -a build.artifacts`) so +# CI can treat their results as advisory rather than blocking - not every +# v3 operation is implemented by keystone-rs yet, so failures here track +# compatibility gaps rather than regressions to fix before merging. +build: + insecureRegistries: + - localhost:5000 + artifacts: + - image: tempest-identity + context: tools/ + docker: + dockerfile: Dockerfile.tempest + local: + concurrency: 0 + +verify: + - &tempest_identity_rust + name: "tempest-identity-rust" + container: + image: "tempest-identity" + name: "tempest-identity-rust" + env: + - name: "TEMPEST_TARGET" + value: "rust" + - name: "OS_AUTH_URL" + value: "http://keystone-rs.default.svc.cluster.local:8080" + - name: "OS_USERNAME" + value: "admin" + - name: "OS_PASSWORD" + value: "password" + - name: "OS_USER_DOMAIN_ID" + value: "default" + - name: "OS_PROJECT_NAME" + value: "admin" + - name: "OS_PROJECT_DOMAIN_ID" + value: "default" + - name: "OS_IDENTITY_SERVICE_TYPE" + value: "identity-rs" + executionMode: + kubernetesCluster: {} + + - &tempest_identity_python + name: "tempest-identity-python" + container: + image: "tempest-identity" + name: "tempest-identity-python" + env: + - name: "TEMPEST_TARGET" + value: "python" + - name: "OS_AUTH_URL" + value: "http://keystone-py.default.svc.cluster.local:5000" + - name: "OS_USERNAME" + value: "admin" + - name: "OS_PASSWORD" + value: "password" + - name: "OS_USER_DOMAIN_ID" + value: "default" + - name: "OS_PROJECT_NAME" + value: "admin" + - name: "OS_PROJECT_DOMAIN_ID" + value: "default" + - name: "OS_IDENTITY_SERVICE_TYPE" + value: "identity" + executionMode: + kubernetesCluster: {} + +profiles: + - name: github-ci + build: + local: + useBuildkit: true + concurrency: 0 + tryImportMissing: true + artifacts: + - image: tempest-identity + context: tools/ + docker: + dockerfile: Dockerfile.tempest + buildArgs: {} + verify: + - *tempest_identity_rust + - *tempest_identity_python + + - name: local + build: + insecureRegistries: + - localhost:5000 + local: + useDockerCLI: true + concurrency: 0 + push: true diff --git a/tests/api/src/auth/token.rs b/tests/api/src/auth/token.rs index 003b6c5ac..8ed92cfc4 100644 --- a/tests/api/src/auth/token.rs +++ b/tests/api/src/auth/token.rs @@ -93,7 +93,7 @@ pub async fn auth_token( .raw_query_async_ll(client, Some(false)) .await?; - if rsp.status() != http::StatusCode::OK { + if rsp.status() != http::StatusCode::CREATED { return Err(eyre!( "Authentication failed with {}: {}", rsp.status(), diff --git a/tests/api/src/common.rs b/tests/api/src/common.rs index 42bf47846..05ca00d2c 100644 --- a/tests/api/src/common.rs +++ b/tests/api/src/common.rs @@ -83,7 +83,7 @@ impl TestClient { .send() .await?; - if rsp.status() != StatusCode::OK { + if rsp.status() != StatusCode::CREATED { return Err(authentication_error(rsp).await); } @@ -203,7 +203,7 @@ impl TestClient { .send() .await?; - if rsp.status() != StatusCode::OK { + if rsp.status() != StatusCode::CREATED { return Err(authentication_error(rsp).await); } diff --git a/tests/api/src/webauthn.rs b/tests/api/src/webauthn.rs index 2a55fa7f6..dfdd74b80 100644 --- a/tests/api/src/webauthn.rs +++ b/tests/api/src/webauthn.rs @@ -281,7 +281,7 @@ pub async fn auth_passkey>( ) .await?; - if rsp.status() != StatusCode::OK { + if rsp.status() != StatusCode::CREATED { return Err(eyre!("Authentication failed with {}", rsp.status())); } diff --git a/tools/Dockerfile.skaffold b/tools/Dockerfile.skaffold index 9e3671d4c..aa52825d1 100644 --- a/tools/Dockerfile.skaffold +++ b/tools/Dockerfile.skaffold @@ -83,9 +83,10 @@ FROM debian:trixie-slim LABEL maintainer="Artem Goncharov" ARG OPA_VERSION=1.16.2 +ARG OSC_VERSION=0.13.7 RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates libssl-dev libssl3 pkg-config && \ + ca-certificates libssl-dev libssl3 pkg-config xz-utils jq curl && \ rm -rf /var/lib/apt/lists/* && update-ca-certificates COPY --from=builder /app/keystone /usr/local/bin/ @@ -95,6 +96,12 @@ COPY --from=builder /app/reference_plugin.wasm /usr/local/lib/reference_plugin.w ADD https://github.com/open-policy-agent/opa/releases/download/v${OPA_VERSION}/opa_linux_amd64_static /usr/local/bin/opa RUN chmod 755 /usr/local/bin/opa +#ADD https://github.com/gtema/openstack/releases/download/v${OSC_VERSION}/openstack_cli-x86_64-unknown-linux-musl.tar.xz /tmp/osc.tar.xz +#RUN tar -xf /tmp/osc.tar.xz -C /tmp && \ +# cp /tmp/openstack_cli-x86_64-unknown-linux-musl/osc /usr/local/bin/ && \ +# chmod 755 /usr/local/bin/osc && \ +# rm -rf /tmp/osc.tar.xz /tmp/openstack_cli-x86_64-unknown-linux-musl + COPY policy /policy COPY --from=builder /app/test-api-v3 /tests/ diff --git a/tools/Dockerfile.tempest b/tools/Dockerfile.tempest new file mode 100644 index 000000000..d2d88d9ae --- /dev/null +++ b/tools/Dockerfile.tempest @@ -0,0 +1,14 @@ +FROM python:3.12-bookworm + +LABEL org.opencontainers.image.description "Tempest identity tests for Keystone Rust/Python compatibility tracking" + +RUN apt-get update && apt-get install -y --no-install-recommends gettext-base && \ + rm -rf /var/lib/apt/lists/* + +RUN pip3 install --no-cache-dir tempest + +COPY tempest/tempest.conf.template /etc/tempest/tempest.conf.template +COPY tempest/run-tempest.sh /usr/local/bin/run-tempest.sh +RUN chmod +x /usr/local/bin/run-tempest.sh + +ENTRYPOINT ["/usr/local/bin/run-tempest.sh"] diff --git a/tools/k8s/keystone/base/bootstrap-job.yaml b/tools/k8s/keystone/base/bootstrap-job.yaml index 0a2dd4bb5..93720e1e3 100644 --- a/tools/k8s/keystone/base/bootstrap-job.yaml +++ b/tools/k8s/keystone/base/bootstrap-job.yaml @@ -30,9 +30,8 @@ spec: - mountPath: /var/log/keystone name: keystone-logs - mountPath: /etc/keystone/keystone.conf - name: config + name: keystone-config subPath: keystone.conf - - env: - name: OS_DATABASE__CONNECTION valueFrom: @@ -46,11 +45,10 @@ spec: - mountPath: /var/log/keystone name: keystone-logs - mountPath: /etc/keystone/keystone.conf - name: config + name: keystone-config subPath: keystone.conf containers: - - env: - name: OS_DATABASE__CONNECTION valueFrom: @@ -59,12 +57,12 @@ spec: name: keystone-db-app image: py-keystone name: keystone-bootstrap - command: [keystone-manage, "bootstrap", "--bootstrap-username", "admin", "--bootstrap-password", "password", "--bootstrap-public-url", "http://keystone.local:80", "--bootstrap-internal-url", "http://keystone-rs.local:80"] + command: [keystone-manage, "bootstrap", "--bootstrap-username", "admin", "--bootstrap-password", "password", "--bootstrap-public-url", "http://keystone-py.local", "--bootstrap-internal-url", "http://keystone-py.default.svc.cluster.local:5000", "--bootstrap-region-id", "RegionOne"] volumeMounts: - mountPath: /var/log/keystone name: keystone-logs - mountPath: /etc/keystone/keystone.conf - name: config + name: keystone-config subPath: keystone.conf - mountPath: /etc/keystone/fernet-keys name: fernet @@ -72,10 +70,9 @@ spec: volumes: - name: keystone-logs emptyDir: {} - - name: "config" + - name: keystone-config configMap: name: "keystone" - - name: "fernet" secret: defaultMode: 420 diff --git a/tools/k8s/keystone/base/deployment-py.yaml b/tools/k8s/keystone/base/deployment-py.yaml index bab814327..f3e527cf3 100644 --- a/tools/k8s/keystone/base/deployment-py.yaml +++ b/tools/k8s/keystone/base/deployment-py.yaml @@ -41,7 +41,7 @@ spec: secretKeyRef: key: fqdn-uri name: keystone-db-app - command: ["uwsgi", "--http-socket", ":5000", "-b", "65535", "-w", "keystone.server.wsgi:initialize_public_application()"] + command: ["uwsgi", "--http-socket", ":5000", "-b", "65535", "-w", "keystone.server.wsgi:initialize_public_application()", "-p", "4"] image: py-keystone livenessProbe: failureThreshold: 3 diff --git a/tools/tempest/run-tempest.sh b/tools/tempest/run-tempest.sh new file mode 100755 index 000000000..0f475594e --- /dev/null +++ b/tools/tempest/run-tempest.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# Entrypoint for the tempest-identity skaffold verify container. +# +# Runs tempest's identity v3 tests against the Keystone deployment pointed +# to by the OS_* env vars, and prints the list of failing test IDs so +# compatibility gaps are visible in the pod's log stream. The real tempest +# exit code is preserved - this script itself never swallows failures; the +# CI job decides separately (via a non-blocking step) not to gate on it, so +# behavior here is identical whether run locally or in CI. +set -u + +: "${OS_AUTH_URL:?OS_AUTH_URL is required}" +: "${OS_USERNAME:?OS_USERNAME is required}" +: "${OS_PASSWORD:?OS_PASSWORD is required}" +: "${OS_USER_DOMAIN_ID:?OS_USER_DOMAIN_ID is required}" +: "${OS_PROJECT_NAME:?OS_PROJECT_NAME is required}" +: "${OS_PROJECT_DOMAIN_ID:?OS_PROJECT_DOMAIN_ID is required}" + +TEMPEST_TARGET="${TEMPEST_TARGET:-unknown}" +TEMPEST_REGEX="${TEMPEST_REGEX:-tempest\.api\.identity\.(v3|admin\.v3)\.}" +WORKSPACE=/tempest/workspace + +export OS_AUTH_URL OS_USERNAME OS_PASSWORD OS_USER_DOMAIN_ID OS_PROJECT_NAME OS_PROJECT_DOMAIN_ID + +mkdir -p "${WORKSPACE}" +tempest init "${WORKSPACE}" +envsubst "${WORKSPACE}/etc/tempest.conf" + +cd "${WORKSPACE}" + +echo "=== Running tempest identity tests against target=${TEMPEST_TARGET} (${OS_AUTH_URL}) ===" +tempest run --config-file "${WORKSPACE}/etc/tempest.conf" --regex "${TEMPEST_REGEX}" +RESULT=$? + +echo "" +echo "===== FAILED TESTS (target=${TEMPEST_TARGET}) =====" +stestr failing --list >/tmp/failed-tests.txt 2>/dev/null +if [ -s /tmp/failed-tests.txt ]; then + cat /tmp/failed-tests.txt +else + echo "(none)" +fi +echo "===== END FAILED TESTS (target=${TEMPEST_TARGET}) =====" + +exit "${RESULT}" diff --git a/tools/tempest/tempest.conf.template b/tools/tempest/tempest.conf.template new file mode 100644 index 000000000..79a7b3b32 --- /dev/null +++ b/tools/tempest/tempest.conf.template @@ -0,0 +1,48 @@ +# Minimal tempest config for the identity-only compatibility verify test. +# Rendered via `envsubst` in run-tempest.sh; only identity-related sections +# are populated since no other OpenStack services are deployed alongside +# Keystone in this environment. + +[DEFAULT] +log_file = /tempest/workspace/tempest.log +debug = True + +[identity] +auth_version = v3 +uri = ${OS_AUTH_URL} +uri_v3 = ${OS_AUTH_URL}/v3 +disable_ssl_certificate_validation = true +region = RegionOne +default_domain_id = ${OS_PROJECT_DOMAIN_ID} +v3_endpoint_type = internal +catalog_type = ${OS_IDENTITY_SERVICE_TYPE} + +[identity-feature-enabled] +api_v2 = false +api_v2_admin = false +api_v3 = true +trust = true +forbid_global_implied_dsr = true +application_credentials = true +access_rules = false +security_compliance = false +project_tags = true +domain_specific_drivers = false + +[auth] +use_dynamic_credentials = true +admin_username = ${OS_USERNAME} +admin_password = ${OS_PASSWORD} +admin_project_name = ${OS_PROJECT_NAME} +admin_domain_name = Default + +[service_available] +cinder = false +neutron = false +glance = false +nova = false +swift = false +heat = false + +[oslo_concurrency] +lock_path = /tmp/tempest-lock