Compare commits

..

31 commits
gating ... main

Author SHA1 Message Date
robojerk
080513ad6d did stuff 2025-08-26 11:15:33 -07:00
Michal Gold
7391652e17 Wizard: Replace deprecated innerRef with ref in RegionsSelect MenuToggle
Replace `innerRef` prop with standard React `ref` prop in MenuToggle component
2025-08-21 19:42:39 +00:00
Gianluca Zuccarelli
9a17373234 Hooks: extract auth.getUser to its own hook
This code was being called in multiple places and was causing issues
with the on-prem frontend. Extract the logic to a single hook and only
get the `userData` for the hosted frontend.
2025-08-21 16:12:09 +00:00
schutzbot
d7f844b8b6 Post release version bump
[skip ci]
2025-08-21 15:34:33 +00:00
Gianluca Zuccarelli
859b7cace8 Wizard: on-prem aws region in edit
The AWS region was getting reset when going into edit mode for a blueprint.
This was because the request wasn't being properly mapped back to the correct
state.
2025-08-21 13:45:26 +00:00
Gianluca Zuccarelli
3a83a14720 BlueprintCard: fix name truncation
This might be an issue with the pf6 truncate component. Since we're not
really using the popover, we can just use vanilla js to truncate the
string rather than use the Truncate component. We can match the
behaviour of the component by also splitting on 24 characters.

https://github.com/patternfly/patternfly-react/issues/11964
2025-08-21 13:44:58 +00:00
Anna Vítová
e61cb99f1b Launch: implement guidance for Azure (HMS-9003)
This commit adds launch modal for guiding users through launching an
Azure instance from their image. As the launch service will be decommissioned,
the flag shall be turned on, the code will later be cleaned up and the
Provisioning wizard removed.
2025-08-21 12:02:20 +00:00
Michal Gold
a5aa15cbcb Wizard: Resolve row reordering issue on selection and expansion
- Fix issue when clicking the expandable arrow or selecting a package checkbox in the Packages step it caused unexpected row reordering.
- Updated sorting logic to ensure that selecting a package with a specific stream groups all related module streams together at the top.
- Ensured that rows expand in place and selection does not affect row position.
- Add unit test as well
2025-08-21 10:17:06 +00:00
Gianluca Zuccarelli
44c3674072 devDeps: Bump msw from 2.10.4 to 2.10.5
This bumps msw from 2.10.4 to 2.10.5
2025-08-21 10:05:42 +00:00
Anna Vítová
4d783537fb Launch: implement guidance for Oracle (HMS-9004)
This commit adds launch modal for guiding users through launching a Oracle instance from their image. It provides a link to Oracle's cloud and a link for importing the image on the user's side.
2025-08-21 07:42:26 +00:00
Gianluca Zuccarelli
0b96c64c93 devDeps: Bump typescript deps
This bumps @typescript-eslint/eslint-plugin and
@typescript-eslint/parser from 8.40.0 to 8.40.0.
These need to be bumped in tandem.
2025-08-20 19:52:49 +00:00
dependabot[bot]
0d917c3cd8 build(deps-dev): bump @types/node from 24.1.0 to 24.3.0
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.1.0 to 24.3.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-20 15:56:27 +00:00
Sanne Raymaekers
957700adcc .gitlab-ci.yml: switch to rhel-10.1 nightly 2025-08-20 12:32:43 +00:00
Sanne Raymaekers
fa0560ac4d playwright: wait until distro and arch have been initialized
On-prem the distro and architecture are set after the wizard has been
opened. This triggers a reload of the image types and makes the tests
very flaky.
2025-08-20 12:32:43 +00:00
Sanne Raymaekers
0e7f5d9e7b plans: add gating tests
This tmt[0] test runs the playwright tests as gating tests. Having the
gating tests upstream avoids duplication across fedora and centos
dist-git repositories, and running them upstream should keep them in
working order.

Only add x86_64 for now, the aarch runners seem to be a bit too slow.

[0]: https://tmt.readthedocs.io/en/stable/index.html
2025-08-20 12:32:43 +00:00
schutzbot
e0dd33fdc9 Post release version bump
[skip ci]
2025-08-20 08:35:04 +00:00
dependabot[bot]
b0393a5f4f build(deps-dev): bump typescript-eslint from 8.38.0 to 8.40.0
Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.38.0 to 8.40.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.40.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.40.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-19 14:46:01 +00:00
Anna Vítová
a9d2ba59a8 Launch: implement guidance for GCP (HMS-9004)
This commit adds launch modal for guiding users through launching a GCP
instance from their image. This commit also adds unique image name in
the command in the clipboard. That way, users can rebuild the image more
times without worrying about duplicate names. This guidance should be as
helpful to users as possible, so even if they are able to create their
own image name here, we chose it for them for the sake of simplicity.
2025-08-19 09:24:45 +00:00
Katarina Sieklova
af19251f17 build(deps): bump @redhat-cloud-services/frontend-components-notifications from 6.1.3 to 6.1.5 2025-08-18 17:55:45 +00:00
Anna Vítová
090544c333 Launch: implement guidance for AWS (HMS-9002)
This commit adds launch modal for guiding users through launching an AWS
instance from their image. As the launch service will be decommissioned,
the flag shall be turned on, the code will later be cleaned up and the
Provisioning wizard removed.
2025-08-18 15:57:58 +00:00
dependabot[bot]
4b188a0393 build(deps): bump @sentry/webpack-plugin from 4.1.0 to 4.1.1
Bumps [@sentry/webpack-plugin](https://github.com/getsentry/sentry-javascript-bundler-plugins) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/getsentry/sentry-javascript-bundler-plugins/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: "@sentry/webpack-plugin"
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-18 10:09:28 +00:00
dependabot[bot]
63f55c7408 build(deps-dev): bump eslint from 9.32.0 to 9.33.0
Bumps [eslint](https://github.com/eslint/eslint) from 9.32.0 to 9.33.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.32.0...v9.33.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.33.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-18 07:54:24 +00:00
dependabot[bot]
bc3288a83e build(deps-dev): bump eslint-plugin-prettier from 5.5.3 to 5.5.4
Bumps [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) from 5.5.3 to 5.5.4.
- [Release notes](https://github.com/prettier/eslint-plugin-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-plugin-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-plugin-prettier/compare/v5.5.3...v5.5.4)

---
updated-dependencies:
- dependency-name: eslint-plugin-prettier
  dependency-version: 5.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-18 07:54:05 +00:00
regexowl
3e5c5dca76 devDeps: Bump typescript deps
This bumps @typescript-eslint/eslint-plugin and @typescript-eslint/parser from 8.39.0 to 8.39.1

These need to be bumped in tandem.
2025-08-17 10:09:21 +00:00
regexowl
04f0528701 devDeps: Bump stylelint deps
This bumps stylelint-config-recommended-scss from 15.0.1 to 16.0.0 and stylelint from 16.23.0 to 16.23.1, these need to be bumped together
2025-08-17 09:46:42 +00:00
red-hat-konflux[bot]
3e2e9dcaa6 chore(deps): update konflux references
Signed-off-by: red-hat-konflux <126015336+red-hat-konflux[bot]@users.noreply.github.com>
2025-08-17 09:46:00 +00:00
Katarina Sieklova
54e413f459 Wizard: fix overflowing bp name
Truncate the name of a bp if it's too long and does not fit the Blueprint card on landing page. Not sure truncating is what we want tho.

Fixes #3034
2025-08-15 07:35:46 +00:00
dependabot[bot]
f6f6e58449 build(deps): bump @patternfly/patternfly from 6.3.0 to 6.3.1
Bumps [@patternfly/patternfly](https://github.com/patternfly/patternfly) from 6.3.0 to 6.3.1.
- [Release notes](https://github.com/patternfly/patternfly/releases)
- [Changelog](https://github.com/patternfly/patternfly/blob/main/release.config.js)
- [Commits](https://github.com/patternfly/patternfly/compare/v6.3.0...v6.3.1)

---
updated-dependencies:
- dependency-name: "@patternfly/patternfly"
  dependency-version: 6.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-15 06:25:42 +00:00
Michal Gold
bf77501eea Wizard: Display customized policy rules in review summary
Previously, when a user selected a compliance policy with tailored rules,
the review page always showed the default profile customizations instead
of the policy-specific customizations.

Root cause: OscapProfileInformation component was only using the profile
endpoint (/oscap/{distribution}/{profile}/customizations) which returns
base profile rules, not the policy endpoint
(/oscap/{policy}/{distribution}/policy_customizations) which returns
customized rules.

Changes:
- Add useGetOscapCustomizationsForPolicyQuery export to backendApi
- Implement dual data fetching in OscapProfileInformation:
  * Profile endpoint: for description and reference ID
  * Policy endpoint: for customized packages, services, kernel args

Fixes the compliance policy customization display bug where edited
policy rules were not reflected in the image build summary.

Add unit tests for compliance policy customizations

Fix profile description title
2025-08-14 19:42:19 +00:00
dependabot[bot]
42b16bafd8 build(deps): bump @patternfly/react-core from 6.3.0 to 6.3.1
Bumps [@patternfly/react-core](https://github.com/patternfly/patternfly-react) from 6.3.0 to 6.3.1.
- [Release notes](https://github.com/patternfly/patternfly-react/releases)
- [Commits](https://github.com/patternfly/patternfly-react/compare/@patternfly/react-core@6.3.0...@patternfly/react-core@6.3.1)

---
updated-dependencies:
- dependency-name: "@patternfly/react-core"
  dependency-version: 6.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-14 14:05:46 +00:00
Lucas Garfield
04adcc133c Wizard: add AAP step 2025-08-14 11:28:49 +00:00
57 changed files with 2743 additions and 1801 deletions

257
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,257 @@
---
name: Debian Image Builder Frontend CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
workflow_dispatch:
env:
NODE_VERSION: "18"
DEBIAN_FRONTEND: noninteractive
jobs:
build-and-test:
name: Build and Test Frontend
runs-on: ubuntu-latest
container:
image: node:18-bullseye
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
run: |
node --version
npm --version
- name: Install build dependencies
run: |
apt-get update
apt-get install -y \
build-essential \
git \
ca-certificates \
python3
- name: Install Node.js dependencies
run: |
npm ci
npm run build || echo "Build script not found"
- name: Run tests
run: |
if [ -f package.json ] && npm run test; then
npm test
else
echo "No test script found, skipping tests"
fi
- name: Run linting
run: |
if [ -f package.json ] && npm run lint; then
npm run lint
else
echo "No lint script found, skipping linting"
fi
- name: Build production bundle
run: |
if [ -f package.json ] && npm run build; then
npm run build
else
echo "No build script found"
fi
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: frontend-build
path: |
dist/
build/
retention-days: 30
package:
name: Package Frontend
runs-on: ubuntu-latest
container:
image: node:18-bullseye
needs: build-and-test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
run: |
node --version
npm --version
- name: Install build dependencies
run: |
apt-get update
apt-get install -y \
build-essential \
devscripts \
debhelper \
git \
ca-certificates \
python3
- name: Install Node.js dependencies
run: npm ci
- name: Build production bundle
run: |
if [ -f package.json ] && npm run build; then
npm run build
else
echo "No build script found"
fi
- name: Create debian directory
run: |
mkdir -p debian
cat > debian/control << EOF
Source: debian-image-builder-frontend
Section: web
Priority: optional
Maintainer: Debian Forge Team <team@debian-forge.org>
Build-Depends: debhelper (>= 13), nodejs, npm, git, ca-certificates
Standards-Version: 4.6.2
Package: debian-image-builder-frontend
Architecture: all
Depends: \${misc:Depends}, nodejs, nginx
Description: Debian Image Builder Frontend
Web-based frontend for Debian Image Builder with Cockpit integration.
Provides a user interface for managing image builds, blueprints,
and system configurations through a modern React application.
EOF
cat > debian/rules << EOF
#!/usr/bin/make -f
%:
dh \$@
override_dh_auto_install:
dh_auto_install
mkdir -p debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend
mkdir -p debian/debian-image-builder-frontend/etc/nginx/sites-available
mkdir -p debian/debian-image-builder-frontend/etc/cockpit
# Copy built frontend files
if [ -d dist ]; then
cp -r dist/* debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
elif [ -d build ]; then
cp -r build/* debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
fi
# Copy source files for development
cp -r src debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
cp package.json debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
# Create nginx configuration
cat > debian/debian-image-builder-frontend/etc/nginx/sites-available/debian-image-builder-frontend << 'NGINX_EOF'
server {
listen 80;
server_name localhost;
root /usr/share/debian-image-builder-frontend;
index index.html;
location / {
try_files \$uri \$uri/ /index.html;
}
location /api/ {
proxy_pass http://localhost:8080/;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
}
NGINX_EOF
# Create cockpit manifest
cat > debian/debian-image-builder-frontend/etc/cockpit/debian-image-builder.manifest << 'COCKPIT_EOF'
{
"version": 1,
"manifest": {
"name": "debian-image-builder",
"version": "1.0.0",
"title": "Debian Image Builder",
"description": "Build and manage Debian atomic images",
"url": "/usr/share/debian-image-builder-frontend",
"icon": "debian-logo",
"requires": {
"cockpit": ">= 200"
}
}
}
COCKPIT_EOF
EOF
cat > debian/changelog << EOF
debian-image-builder-frontend (1.0.0-1) unstable; urgency=medium
* Initial release
* Debian Image Builder Frontend with Cockpit integration
* React-based web interface for image management
-- Debian Forge Team <team@debian-forge.org> $(date -R)
EOF
cat > debian/compat << EOF
13
EOF
chmod +x debian/rules
- name: Build Debian package
run: |
dpkg-buildpackage -us -uc -b
ls -la ../*.deb
- name: Upload Debian package
uses: actions/upload-artifact@v4
with:
name: debian-image-builder-frontend-deb
path: ../*.deb
retention-days: 30
cockpit-integration:
name: Test Cockpit Integration
runs-on: ubuntu-latest
container:
image: node:18-bullseye
needs: build-and-test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
run: |
node --version
npm --version
- name: Install Node.js dependencies
run: npm ci
- name: Test cockpit integration
run: |
echo "Testing Cockpit integration..."
if [ -d cockpit ]; then
echo "Cockpit directory found:"
ls -la cockpit/
else
echo "No cockpit directory found"
fi
if [ -f package.json ]; then
echo "Package.json scripts:"
npm run
fi

View file

@ -0,0 +1,257 @@
---
name: Debian Image Builder Frontend CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
workflow_dispatch:
env:
NODE_VERSION: "18"
DEBIAN_FRONTEND: noninteractive
jobs:
build-and-test:
name: Build and Test Frontend
runs-on: ubuntu-latest
container:
image: node:18-bullseye
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
run: |
node --version
npm --version
- name: Install build dependencies
run: |
apt-get update
apt-get install -y \
build-essential \
git \
ca-certificates \
python3
- name: Install Node.js dependencies
run: |
npm ci
npm run build || echo "Build script not found"
- name: Run tests
run: |
if [ -f package.json ] && npm run test; then
npm test
else
echo "No test script found, skipping tests"
fi
- name: Run linting
run: |
if [ -f package.json ] && npm run lint; then
npm run lint
else
echo "No lint script found, skipping linting"
fi
- name: Build production bundle
run: |
if [ -f package.json ] && npm run build; then
npm run build
else
echo "No build script found"
fi
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: frontend-build
path: |
dist/
build/
retention-days: 30
package:
name: Package Frontend
runs-on: ubuntu-latest
container:
image: node:18-bullseye
needs: build-and-test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
run: |
node --version
npm --version
- name: Install build dependencies
run: |
apt-get update
apt-get install -y \
build-essential \
devscripts \
debhelper \
git \
ca-certificates \
python3
- name: Install Node.js dependencies
run: npm ci
- name: Build production bundle
run: |
if [ -f package.json ] && npm run build; then
npm run build
else
echo "No build script found"
fi
- name: Create debian directory
run: |
mkdir -p debian
cat > debian/control << EOF
Source: debian-image-builder-frontend
Section: web
Priority: optional
Maintainer: Debian Forge Team <team@debian-forge.org>
Build-Depends: debhelper (>= 13), nodejs, npm, git, ca-certificates
Standards-Version: 4.6.2
Package: debian-image-builder-frontend
Architecture: all
Depends: \${misc:Depends}, nodejs, nginx
Description: Debian Image Builder Frontend
Web-based frontend for Debian Image Builder with Cockpit integration.
Provides a user interface for managing image builds, blueprints,
and system configurations through a modern React application.
EOF
cat > debian/rules << EOF
#!/usr/bin/make -f
%:
dh \$@
override_dh_auto_install:
dh_auto_install
mkdir -p debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend
mkdir -p debian/debian-image-builder-frontend/etc/nginx/sites-available
mkdir -p debian/debian-image-builder-frontend/etc/cockpit
# Copy built frontend files
if [ -d dist ]; then
cp -r dist/* debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
elif [ -d build ]; then
cp -r build/* debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
fi
# Copy source files for development
cp -r src debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
cp package.json debian/debian-image-builder-frontend/usr/share/debian-image-builder-frontend/
# Create nginx configuration
cat > debian/debian-image-builder-frontend/etc/nginx/sites-available/debian-image-builder-frontend << 'NGINX_EOF'
server {
listen 80;
server_name localhost;
root /usr/share/debian-image-builder-frontend;
index index.html;
location / {
try_files \$uri \$uri/ /index.html;
}
location /api/ {
proxy_pass http://localhost:8080/;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
}
NGINX_EOF
# Create cockpit manifest
cat > debian/debian-image-builder-frontend/etc/cockpit/debian-image-builder.manifest << 'COCKPIT_EOF'
{
"version": 1,
"manifest": {
"name": "debian-image-builder",
"version": "1.0.0",
"title": "Debian Image Builder",
"description": "Build and manage Debian atomic images",
"url": "/usr/share/debian-image-builder-frontend",
"icon": "debian-logo",
"requires": {
"cockpit": ">= 200"
}
}
}
COCKPIT_EOF
EOF
cat > debian/changelog << EOF
debian-image-builder-frontend (1.0.0-1) unstable; urgency=medium
* Initial release
* Debian Image Builder Frontend with Cockpit integration
* React-based web interface for image management
-- Debian Forge Team <team@debian-forge.org> $(date -R)
EOF
cat > debian/compat << EOF
13
EOF
chmod +x debian/rules
- name: Build Debian package
run: |
dpkg-buildpackage -us -uc -b
ls -la ../*.deb
- name: Upload Debian package
uses: actions/upload-artifact@v4
with:
name: debian-image-builder-frontend-deb
path: ../*.deb
retention-days: 30
cockpit-integration:
name: Test Cockpit Integration
runs-on: ubuntu-latest
container:
image: node:18-bullseye
needs: build-and-test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
run: |
node --version
npm --version
- name: Install Node.js dependencies
run: npm ci
- name: Test cockpit integration
run: |
echo "Testing Cockpit integration..."
if [ -d cockpit ]; then
echo "Cockpit directory found:"
ls -la cockpit/
else
echo "No cockpit directory found"
fi
if [ -f package.json ]; then
echo "Package.json scripts:"
npm run
fi

View file

@ -32,8 +32,7 @@ test:
- RUNNER:
- aws/fedora-41-x86_64
- aws/fedora-42-x86_64
- aws/rhel-9.6-nightly-x86_64
- aws/rhel-10.0-nightly-x86_64
- aws/rhel-10.1-nightly-x86_64
INTERNAL_NETWORK: ["true"]
finish:

View file

@ -194,7 +194,7 @@ spec:
- name: name
value: prefetch-dependencies
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies:0.2@sha256:76bc23ca84c6b31251fee267f417ed262d9ea49655e942d1978149e137295bb0
value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies:0.2@sha256:ce5f2485d759221444357fe38276be876fc54531651e50dcfc0f84b34909d760
- name: kind
value: task
resolver: bundles
@ -238,7 +238,7 @@ spec:
- name: name
value: buildah
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-buildah:0.4@sha256:09ca1ce263bb686b08187a3b836f6a5bd54875e8596105e4d7d816d57caf55a0
value: quay.io/konflux-ci/tekton-catalog/task-buildah:0.4@sha256:7782cb7462130de8e8839a58dd15ed78e50938d718b51375267679c6044b4367
- name: kind
value: task
resolver: bundles
@ -413,7 +413,7 @@ spec:
- name: name
value: ecosystem-cert-preflight-checks
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:4bafcaab0f0c998a89a1cc33bdbbf74f39eea52e6c0e43013c356a322f94940f
value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:1f151e00f7fc427654b7b76045a426bb02fe650d192ffe147a304d2184787e38
- name: kind
value: task
resolver: bundles
@ -503,7 +503,7 @@ spec:
- name: name
value: push-dockerfile
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile:0.1@sha256:5446102f233991bdc73451201adf361766d45c638f3d89f19121ae1c2ba8bf17
value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile:0.1@sha256:d5cb22a833be51dd72a872cac8bfbe149e8ad34da7cb48a643a1e613447a1f9d
- name: kind
value: task
resolver: bundles

View file

@ -191,7 +191,7 @@ spec:
- name: name
value: prefetch-dependencies
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies:0.2@sha256:76bc23ca84c6b31251fee267f417ed262d9ea49655e942d1978149e137295bb0
value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies:0.2@sha256:ce5f2485d759221444357fe38276be876fc54531651e50dcfc0f84b34909d760
- name: kind
value: task
resolver: bundles
@ -235,7 +235,7 @@ spec:
- name: name
value: buildah
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-buildah:0.4@sha256:09ca1ce263bb686b08187a3b836f6a5bd54875e8596105e4d7d816d57caf55a0
value: quay.io/konflux-ci/tekton-catalog/task-buildah:0.4@sha256:7782cb7462130de8e8839a58dd15ed78e50938d718b51375267679c6044b4367
- name: kind
value: task
resolver: bundles
@ -410,7 +410,7 @@ spec:
- name: name
value: ecosystem-cert-preflight-checks
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:4bafcaab0f0c998a89a1cc33bdbbf74f39eea52e6c0e43013c356a322f94940f
value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:1f151e00f7fc427654b7b76045a426bb02fe650d192ffe147a304d2184787e38
- name: kind
value: task
resolver: bundles
@ -500,7 +500,7 @@ spec:
- name: name
value: push-dockerfile
- name: bundle
value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile:0.1@sha256:5446102f233991bdc73451201adf361766d45c638f3d89f19121ae1c2ba8bf17
value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile:0.1@sha256:d5cb22a833be51dd72a872cac8bfbe149e8ad34da7cb48a643a1e613447a1f9d
- name: kind
value: task
resolver: bundles

View file

@ -1,5 +1,5 @@
Name: cockpit-image-builder
Version: 74
Version: 76
Release: 1%{?dist}
Summary: Image builder plugin for Cockpit

1848
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,16 +8,17 @@
},
"dependencies": {
"@ltd/j-toml": "1.38.0",
"@patternfly/patternfly": "6.3.0",
"@patternfly/patternfly": "6.3.1",
"@patternfly/react-code-editor": "6.3.1",
"@patternfly/react-core": "6.3.0",
"@patternfly/react-core": "6.3.1",
"@patternfly/react-table": "6.3.1",
"@redhat-cloud-services/frontend-components": "7.0.3",
"@redhat-cloud-services/frontend-components-notifications": "6.1.3",
"@redhat-cloud-services/frontend-components-notifications": "6.1.5",
"@redhat-cloud-services/frontend-components-utilities": "7.0.3",
"@redhat-cloud-services/types": "3.0.1",
"@reduxjs/toolkit": "2.8.2",
"@scalprum/react-core": "0.9.5",
"@sentry/webpack-plugin": "4.1.0",
"@sentry/webpack-plugin": "4.1.1",
"@unleash/proxy-client-react": "5.0.1",
"classnames": "2.5.1",
"jwt-decode": "4.0.0",
@ -46,13 +47,13 @@
"@testing-library/jest-dom": "6.6.4",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/node": "24.1.0",
"@types/node": "24.3.0",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@types/react-redux": "7.1.34",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.39.0",
"@typescript-eslint/parser": "8.39.0",
"@typescript-eslint/eslint-plugin": "8.40.0",
"@typescript-eslint/parser": "8.40.0",
"@vitejs/plugin-react": "4.7.0",
"@vitest/coverage-v8": "3.2.4",
"babel-loader": "10.0.0",
@ -61,13 +62,13 @@
"chartjs-plugin-annotation": "3.1.0",
"copy-webpack-plugin": "13.0.0",
"css-loader": "7.1.2",
"eslint": "9.32.0",
"eslint": "9.33.0",
"eslint-plugin-disable-autofix": "5.0.1",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jest-dom": "5.5.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-playwright": "2.2.2",
"eslint-plugin-prettier": "5.5.3",
"eslint-plugin-prettier": "5.5.4",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-redux": "4.2.2",
@ -80,7 +81,7 @@
"madge": "8.0.0",
"mini-css-extract-plugin": "2.9.2",
"moment": "2.30.1",
"msw": "2.10.4",
"msw": "2.10.5",
"npm-run-all": "4.1.5",
"path-browserify": "1.0.1",
"postcss-scss": "4.0.9",
@ -88,12 +89,12 @@
"redux-mock-store": "1.5.5",
"sass": "1.90.0",
"sass-loader": "16.0.5",
"stylelint": "16.23.0",
"stylelint-config-recommended-scss": "15.0.1",
"stylelint": "16.23.1",
"stylelint-config-recommended-scss": "16.0.0",
"ts-node": "10.9.2",
"ts-patch": "3.3.0",
"typescript": "5.8.3",
"typescript-eslint": "8.38.0",
"typescript-eslint": "8.40.0",
"uuid": "11.1.0",
"vitest": "3.2.4",
"vitest-canvas-mock": "0.3.3",

View file

@ -22,10 +22,8 @@ jobs:
tmt_plan: /plans/all/main
targets:
- centos-stream-10
- centos-stream-10-aarch64
- fedora-41
- fedora-42
- fedora-latest-stable-aarch64
- job: copr_build
trigger: pull_request

View file

@ -0,0 +1,214 @@
import { expect } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';
import { test } from '../fixtures/customizations';
import { isHosted } from '../helpers/helpers';
import { ensureAuthenticated } from '../helpers/login';
import {
ibFrame,
navigateToLandingPage,
navigateToOptionalSteps,
} from '../helpers/navHelpers';
import {
createBlueprint,
deleteBlueprint,
exportBlueprint,
fillInDetails,
fillInImageOutputGuest,
importBlueprint,
registerLater,
} from '../helpers/wizardHelpers';
const validCallbackUrl =
'https://controller.url/api/controller/v2/job_templates/9/callback/';
const validHttpCallbackUrl =
'http://controller.url/api/controller/v2/job_templates/9/callback/';
const validHostConfigKey = 'hostconfigkey';
const validCertificate = `-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAOEzx5ezZ9EIMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
BAYTAklOMQswCQYDVQQIDAJLUjEMMAoGA1UEBwwDS1JHMRAwDgYDVQQKDAdUZXN0
IENBMB4XDTI1MDUxNTEyMDAwMFoXDTI2MDUxNTEyMDAwMFowRTELMAkGA1UEBhMC
SU4xCzAJBgNVBAgMAktSMQwwCgYDVQQHDANSR0sxEDAOBgNVBAoMB1Rlc3QgQ0Ew
ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC+R4gfN5pyJQo5qBTTtN+7
eE9CSXZJ8SVVaE3U54IgqQoqsSoBY5QtExy7v5C6l6mW4E6dzK/JecmvTTO/BvlG
A5k2hxB6bOQxtxYwfgElH+RFWN9P4xxhtEiQgHoG1rDfnXuDJk1U3YEkCQELUebz
fF3EIDU1yR0Sz2bA+Sl2VXe8og1MEZfytq8VZUVltxtn2PfW7zI5gOllBR2sKeUc
K6h8HXN7qMgfEvsLIXxTw7fU/zA3ibcxfRCl3m6QhF8hwRh6F9Wtz2s8hCzGegV5
z0M39nY7X8C3GZQ4Ly8v8DdY+FbEix7K3SSBRbWtdPfAHRFlX9Er2Wf8DAr7O2hH
AgMBAAGjUDBOMB0GA1UdDgQWBBTXXz2eIDgK+BhzDUAGzptn0OMcpDAfBgNVHSME
GDAWgBTXXz2eIDgK+BhzDUAGzptn0OMcpDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3
DQEBCwUAA4IBAQAoUgY4jsuBMB3el9cc7JS2rcOhhJzn47Hj2UANfJq52g5lbjo7
XDc7Wb3VDcV+1LzjdzayT1qO1WzHb6FDPW9L9f6h4s8lj6MvJ+xhOWgD11srdIt3
vbQaQW4zDfeVRcKXzqbcUX8BLXAdzJPqVwZ+Z4EDjYrJ7lF9k+IqfZm0MsYX7el9
kvdRHbLuF4Q0sZ05CXMFkhM0Ulhu4MZ+1FcsQa7nWfZzTmbjHOuWJPB4z5WwrB7z
U8YYvWJ3qxToWGbATqJxkRKGGqLrNrmwcfzgPqkpuCRYi0Kky6gJ1RvL+DRopY9x
uD+ckf3oH2wYAB6RpPRMkfVxe7lGMvq/yEZ6
-----END CERTIFICATE-----`;
const invalidCertificate = `-----BEGIN CERTIFICATE-----
ThisIs*Not+Valid/Base64==
-----END CERTIFICATE-----`;
test('Create a blueprint with AAP registration customization', async ({
page,
cleanup,
}) => {
const blueprintName = 'test-' + uuidv4();
// Skip entirely in Cockpit/on-premise where AAP customization is unavailable
test.skip(!isHosted(), 'AAP customization is not available in the plugin');
// Delete the blueprint after the run fixture
await cleanup.add(() => deleteBlueprint(page, blueprintName));
await ensureAuthenticated(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
const frame = await ibFrame(page);
await test.step('Navigate to optional steps in Wizard', async () => {
await navigateToOptionalSteps(frame);
await registerLater(frame);
});
await test.step('Select and fill the AAP step with valid configuration', async () => {
await frame
.getByRole('button', { name: 'Ansible Automation Platform' })
.click();
await frame
.getByRole('textbox', { name: 'ansible callback url' })
.fill(validCallbackUrl);
await frame
.getByRole('textbox', { name: 'host config key' })
.fill(validHostConfigKey);
await frame
.getByRole('textbox', { name: 'File upload' })
.fill(validCertificate);
await expect(frame.getByRole('button', { name: 'Next' })).toBeEnabled();
});
await test.step('Test TLS confirmation checkbox for HTTPS URLs', async () => {
// TLS confirmation checkbox should appear for HTTPS URLs
await expect(
frame.getByRole('checkbox', {
name: 'Insecure',
}),
).toBeVisible();
// Check TLS confirmation and verify CA input is hidden
await frame
.getByRole('checkbox', {
name: 'Insecure',
})
.check();
await expect(
frame.getByRole('textbox', { name: 'File upload' }),
).toBeHidden();
await frame
.getByRole('checkbox', {
name: 'Insecure',
})
.uncheck();
await expect(
frame.getByRole('textbox', { name: 'File upload' }),
).toBeVisible();
});
await test.step('Test certificate validation', async () => {
await frame.getByRole('textbox', { name: 'File upload' }).clear();
await frame
.getByRole('textbox', { name: 'File upload' })
.fill(invalidCertificate);
await expect(frame.getByText(/Certificate.*is not valid/)).toBeVisible();
await frame.getByRole('textbox', { name: 'File upload' }).clear();
await frame
.getByRole('textbox', { name: 'File upload' })
.fill(validCertificate);
await expect(frame.getByText('Certificate was uploaded')).toBeVisible();
});
await test.step('Test HTTP URL behavior', async () => {
await frame.getByRole('textbox', { name: 'ansible callback url' }).clear();
await frame
.getByRole('textbox', { name: 'ansible callback url' })
.fill(validHttpCallbackUrl);
// TLS confirmation checkbox should NOT appear for HTTP URLs
await expect(
frame.getByRole('checkbox', {
name: 'Insecure',
}),
).toBeHidden();
await expect(
frame.getByRole('textbox', { name: 'File upload' }),
).toBeVisible();
await frame.getByRole('textbox', { name: 'ansible callback url' }).clear();
await frame
.getByRole('textbox', { name: 'ansible callback url' })
.fill(validCallbackUrl);
});
await test.step('Complete AAP configuration and proceed to review', async () => {
await frame.getByRole('button', { name: 'Review and finish' }).click();
});
await test.step('Fill the BP details', async () => {
await fillInDetails(frame, blueprintName);
});
await test.step('Create BP', async () => {
await createBlueprint(frame, blueprintName);
});
await test.step('Edit BP and verify AAP configuration persists', async () => {
await frame.getByRole('button', { name: 'Edit blueprint' }).click();
await frame.getByLabel('Revisit Ansible Automation Platform step').click();
await expect(
frame.getByRole('textbox', { name: 'ansible callback url' }),
).toHaveValue(validCallbackUrl);
await expect(
frame.getByRole('textbox', { name: 'host config key' }),
).toHaveValue(validHostConfigKey);
await expect(
frame.getByRole('textbox', { name: 'File upload' }),
).toHaveValue(validCertificate);
await frame.getByRole('button', { name: 'Review and finish' }).click();
await frame
.getByRole('button', { name: 'Save changes to blueprint' })
.click();
});
// This is for hosted service only as these features are not available in cockpit plugin
await test.step('Export BP', async (step) => {
step.skip(!isHosted(), 'Exporting is not available in the plugin');
await exportBlueprint(page, blueprintName);
});
await test.step('Import BP', async (step) => {
step.skip(!isHosted(), 'Importing is not available in the plugin');
await importBlueprint(page, blueprintName);
});
await test.step('Review imported BP', async (step) => {
step.skip(!isHosted(), 'Importing is not available in the plugin');
await fillInImageOutputGuest(page);
await page
.getByRole('button', { name: 'Ansible Automation Platform' })
.click();
await expect(
page.getByRole('textbox', { name: 'ansible callback url' }),
).toHaveValue(validCallbackUrl);
await expect(
page.getByRole('textbox', { name: 'host config key' }),
).toBeEmpty();
await expect(
page.getByRole('textbox', { name: 'File upload' }),
).toHaveValue(validCertificate);
await page.getByRole('button', { name: 'Cancel' }).click();
});
});

View file

@ -67,8 +67,10 @@ export const getHostDistroName = (): string => {
const lineData = l.split('=');
(osRel as any)[lineData[0]] = lineData[1].replace(/"/g, '');
}
// strip minor version from rhel
const distro = ON_PREM_RELEASES.get(
`${(osRel as any)['ID']}-${(osRel as any)['VERSION_ID']}`,
`${(osRel as any)['ID']}-${(osRel as any)['VERSION_ID'].split('.')[0]}`,
);
if (distro === undefined) {

View file

@ -72,6 +72,11 @@ test.describe.serial('test', () => {
frame.getByRole('heading', { name: 'Systemd services' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
if (isHosted()) {
frame.getByRole('heading', { name: 'Ansible Automation Platform' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
}
if (isHosted()) {
frame.getByRole('heading', { name: 'First boot configuration' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
@ -87,7 +92,12 @@ test.describe.serial('test', () => {
await frame.getByRole('button', { name: 'Create blueprint' }).click();
await expect(
frame.locator('.pf-v6-c-card__title-text').getByText(blueprintName),
frame.locator('.pf-v6-c-card__title-text').getByText(
// if the name is too long, the blueprint card will have a truncated name.
blueprintName.length > 24
? blueprintName.slice(0, 24) + '...'
: blueprintName,
),
).toBeVisible();
});

View file

@ -1 +1 @@
7b4735d287dd0950e0a6f47dde65b62b0f239da1
cf0a810fd3b75fa27139746c4dfe72222e13dcba

View file

@ -50,11 +50,21 @@ const BlueprintCard = ({ blueprint }: blueprintProps) => {
onChange: () => dispatch(setBlueprintId(blueprint.id)),
}}
>
<CardTitle>
<CardTitle aria-label={blueprint.name}>
{isLoading && blueprint.id === selectedBlueprintId && (
<Spinner size='md' />
)}
{blueprint.name}
{
// NOTE: This might be an issue with the pf6 truncate component.
// Since we're not really using the popover, we can just
// use vanilla js to truncate the string rather than use the
// Truncate component. We can match the behaviour of the component
// by also splitting on 24 characters.
// https://github.com/patternfly/patternfly-react/issues/11964
blueprint.name && blueprint.name.length > 24
? blueprint.name.slice(0, 24) + '...'
: blueprint.name
}
</CardTitle>
</CardHeader>
<CardBody>{blueprint.description}</CardBody>

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback } from 'react';
import {
Bullseye,
@ -17,7 +17,6 @@ import {
import { PlusCircleIcon, SearchIcon } from '@patternfly/react-icons';
import { SVGIconProps } from '@patternfly/react-icons/dist/esm/createIcon';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { ChromeUser } from '@redhat-cloud-services/types';
import debounce from 'lodash/debounce';
import { Link } from 'react-router-dom';
@ -29,6 +28,7 @@ import {
PAGINATION_LIMIT,
PAGINATION_OFFSET,
} from '../../constants';
import { useGetUser } from '../../Hooks';
import { useGetBlueprintsQuery } from '../../store/backendApi';
import {
selectBlueprintSearchInput,
@ -60,8 +60,8 @@ type emptyBlueprintStateProps = {
};
const BlueprintsSidebar = () => {
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
const { analytics, auth } = useChrome();
const { userData } = useGetUser(auth);
const selectedBlueprintId = useAppSelector(selectSelectedBlueprintId);
const blueprintSearchInput = useAppSelector(selectBlueprintSearchInput);
@ -73,16 +73,6 @@ const BlueprintsSidebar = () => {
offset: blueprintsOffset,
};
useEffect(() => {
(async () => {
const data = await auth.getUser();
setUserData(data);
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (blueprintSearchInput) {
searchParams.search = blueprintSearchInput;
}

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import {
Button,
@ -16,11 +16,13 @@ import {
} from '@patternfly/react-core';
import { MenuToggleElement } from '@patternfly/react-core/dist/esm/components/MenuToggle/MenuToggle';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { ChromeUser } from '@redhat-cloud-services/types';
import { skipToken } from '@reduxjs/toolkit/query';
import { AMPLITUDE_MODULE_NAME, targetOptions } from '../../constants';
import { useComposeBPWithNotification as useComposeBlueprintMutation } from '../../Hooks';
import {
useComposeBPWithNotification as useComposeBlueprintMutation,
useGetUser,
} from '../../Hooks';
import { useGetBlueprintQuery } from '../../store/backendApi';
import { selectSelectedBlueprintId } from '../../store/BlueprintSlice';
import { useAppSelector } from '../../store/hooks';
@ -37,18 +39,7 @@ export const BuildImagesButton = ({ children }: BuildImagesButtonPropTypes) => {
const { trigger: buildBlueprint, isLoading: imageBuildLoading } =
useComposeBlueprintMutation();
const { analytics, auth } = useChrome();
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
useEffect(() => {
(async () => {
const data = await auth.getUser();
setUserData(data);
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { userData } = useGetUser(auth);
const onBuildHandler = async () => {
if (selectedBlueprintId) {

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import {
Button,
@ -9,14 +9,16 @@ import {
ModalVariant,
} from '@patternfly/react-core';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { ChromeUser } from '@redhat-cloud-services/types';
import {
AMPLITUDE_MODULE_NAME,
PAGINATION_LIMIT,
PAGINATION_OFFSET,
} from '../../constants';
import { useDeleteBPWithNotification as useDeleteBlueprintMutation } from '../../Hooks';
import {
useDeleteBPWithNotification as useDeleteBlueprintMutation,
useGetUser,
} from '../../Hooks';
import { backendApi, useGetBlueprintsQuery } from '../../store/backendApi';
import {
selectBlueprintSearchInput,
@ -42,17 +44,7 @@ export const DeleteBlueprintModal: React.FunctionComponent<
const blueprintsLimit = useAppSelector(selectLimit) || PAGINATION_LIMIT;
const dispatch = useAppDispatch();
const { analytics, auth } = useChrome();
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
useEffect(() => {
(async () => {
const data = await auth.getUser();
setUserData(data);
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { userData } = useGetUser(auth);
const searchParams: GetBlueprintsApiArg = {
limit: blueprintsLimit,

View file

@ -15,6 +15,7 @@ import { WizardStepType } from '@patternfly/react-core/dist/esm/components/Wizar
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { useNavigate, useSearchParams } from 'react-router-dom';
import AAPStep from './steps/AAP';
import DetailsStep from './steps/Details';
import FileSystemStep from './steps/FileSystem';
import { FileSystemContext } from './steps/FileSystem/components/FileSystemTable';
@ -40,6 +41,7 @@ import UsersStep from './steps/Users';
import { getHostArch, getHostDistro } from './utilities/getHostInfo';
import { useHasSpecificTargetOnly } from './utilities/hasSpecificTargetOnly';
import {
useAAPValidation,
useDetailsValidation,
useFilesystemValidation,
useFirewallValidation,
@ -197,6 +199,7 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
// Feature flags
const complianceEnabled = useFlag('image-builder.compliance.enabled');
const isAAPRegistrationEnabled = useFlag('image-builder.aap.enabled');
// IMPORTANT: Ensure the wizard starts with a fresh initial state
useEffect(() => {
@ -283,6 +286,8 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
const firewallValidation = useFirewallValidation();
// Services
const servicesValidation = useServicesValidation();
// AAP
const aapValidation = useAAPValidation();
// Firstboot
const firstBootValidation = useFirstBootValidation();
// Details
@ -293,8 +298,10 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
const hasWslTargetOnly = useHasSpecificTargetOnly('wsl');
let startIndex = 1; // default index
const JUMP_TO_REVIEW_STEP = 23;
if (isEdit) {
startIndex = 22;
startIndex = JUMP_TO_REVIEW_STEP;
}
const [wasRegisterVisited, setWasRegisterVisited] = useState(false);
@ -655,6 +662,22 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
>
<ServicesStep />
</WizardStep>,
<WizardStep
name='Ansible Automation Platform'
id='wizard-aap'
isHidden={!isAAPRegistrationEnabled}
key='wizard-aap'
navItem={CustomStatusNavItem}
status={aapValidation.disabledNext ? 'error' : 'default'}
footer={
<CustomWizardFooter
disableNext={aapValidation.disabledNext}
optional={true}
/>
}
>
<AAPStep />
</WizardStep>,
<WizardStep
name='First boot script configuration'
id='wizard-first-boot'

View file

@ -23,7 +23,7 @@ type ValidatedTextInputPropTypes = TextInputProps & {
type ValidationInputProp = TextInputProps &
TextAreaProps & {
value: string;
placeholder: string;
placeholder?: string;
stepValidation: StepValidation;
dataTestId?: string;
fieldName: string;
@ -91,7 +91,7 @@ export const ValidatedInputAndTextArea = ({
onChange={onChange}
validated={validated}
onBlur={handleBlur}
placeholder={placeholder}
placeholder={placeholder || ''}
aria-label={ariaLabel}
data-testid={dataTestId}
/>
@ -138,6 +138,7 @@ export const ValidatedInput = ({
value,
placeholder,
onChange,
...props
}: ValidatedTextInputPropTypes) => {
const [isPristine, setIsPristine] = useState(!value ? true : false);
@ -164,6 +165,7 @@ export const ValidatedInput = ({
aria-label={ariaLabel || ''}
onBlur={handleBlur}
placeholder={placeholder || ''}
{...props}
/>
{!isPristine && !validator(value) && (
<HelperText>

View file

@ -0,0 +1,178 @@
import React from 'react';
import {
Checkbox,
DropEvent,
FileUpload,
FormGroup,
FormHelperText,
HelperText,
HelperTextItem,
} from '@patternfly/react-core';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import {
changeAapCallbackUrl,
changeAapHostConfigKey,
changeAapTlsCertificateAuthority,
changeAapTlsConfirmation,
selectAapCallbackUrl,
selectAapHostConfigKey,
selectAapTlsCertificateAuthority,
selectAapTlsConfirmation,
} from '../../../../../store/wizardSlice';
import { useAAPValidation } from '../../../utilities/useValidation';
import { ValidatedInputAndTextArea } from '../../../ValidatedInput';
import { validateMultipleCertificates } from '../../../validators';
const AAPRegistration = () => {
const dispatch = useAppDispatch();
const callbackUrl = useAppSelector(selectAapCallbackUrl);
const hostConfigKey = useAppSelector(selectAapHostConfigKey);
const tlsCertificateAuthority = useAppSelector(
selectAapTlsCertificateAuthority,
);
const tlsConfirmation = useAppSelector(selectAapTlsConfirmation);
const [isRejected, setIsRejected] = React.useState(false);
const stepValidation = useAAPValidation();
const isHttpsUrl = callbackUrl?.toLowerCase().startsWith('https://') || false;
const shouldShowCaInput = !isHttpsUrl || (isHttpsUrl && !tlsConfirmation);
const validated = stepValidation.errors['certificate']
? 'error'
: stepValidation.errors['certificate'] === undefined &&
tlsCertificateAuthority &&
validateMultipleCertificates(tlsCertificateAuthority).validCertificates
.length > 0
? 'success'
: 'default';
const handleCallbackUrlChange = (value: string) => {
dispatch(changeAapCallbackUrl(value));
};
const handleHostConfigKeyChange = (value: string) => {
dispatch(changeAapHostConfigKey(value));
};
const handleClear = () => {
dispatch(changeAapTlsCertificateAuthority(''));
};
const handleTextChange = (
_event: React.ChangeEvent<HTMLTextAreaElement>,
value: string,
) => {
dispatch(changeAapTlsCertificateAuthority(value));
setIsRejected(false);
};
const handleDataChange = (_: DropEvent, value: string) => {
dispatch(changeAapTlsCertificateAuthority(value));
setIsRejected(false);
};
const handleFileRejected = () => {
dispatch(changeAapTlsCertificateAuthority(''));
setIsRejected(true);
};
const handleTlsConfirmationChange = (checked: boolean) => {
dispatch(changeAapTlsConfirmation(checked));
};
return (
<>
<FormGroup label='Ansible Callback URL' isRequired>
<ValidatedInputAndTextArea
value={callbackUrl || ''}
onChange={(_event, value) => handleCallbackUrlChange(value.trim())}
ariaLabel='ansible callback url'
isRequired
stepValidation={stepValidation}
fieldName='callbackUrl'
/>
</FormGroup>
<FormGroup label='Host Config Key' isRequired>
<ValidatedInputAndTextArea
value={hostConfigKey || ''}
onChange={(_event, value) => handleHostConfigKeyChange(value.trim())}
ariaLabel='host config key'
isRequired
stepValidation={stepValidation}
fieldName='hostConfigKey'
/>
</FormGroup>
{shouldShowCaInput && (
<FormGroup label='Certificate authority (CA) for Ansible Controller'>
<FileUpload
id='aap-certificate-upload'
type='text'
value={tlsCertificateAuthority || ''}
filename={tlsCertificateAuthority ? 'CA detected' : ''}
onDataChange={handleDataChange}
onTextChange={handleTextChange}
onClearClick={handleClear}
dropzoneProps={{
accept: {
'application/x-pem-file': ['.pem'],
'application/x-x509-ca-cert': ['.cer', '.crt'],
'application/pkix-cert': ['.der'],
},
maxSize: 512000,
onDropRejected: handleFileRejected,
}}
validated={isRejected ? 'error' : validated}
browseButtonText='Upload'
allowEditingUploadedText={true}
/>
<FormHelperText>
<HelperText>
<HelperTextItem
variant={
isRejected || validated === 'error'
? 'error'
: validated === 'success'
? 'success'
: 'default'
}
>
{isRejected
? 'Must be a .PEM/.CER/.CRT file'
: validated === 'error'
? stepValidation.errors['certificate']
: validated === 'success'
? 'Certificate was uploaded'
: 'Drag and drop a valid certificate file or upload one'}
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
)}
{isHttpsUrl && (
<FormGroup>
<Checkbox
id='tls-confirmation-checkbox'
label='Insecure'
isChecked={tlsConfirmation || false}
onChange={(_event, checked) => handleTlsConfirmationChange(checked)}
/>
{stepValidation.errors['tlsConfirmation'] && (
<FormHelperText>
<HelperText>
<HelperTextItem variant='error'>
{stepValidation.errors['tlsConfirmation']}
</HelperTextItem>
</HelperText>
</FormHelperText>
)}
</FormGroup>
)}
</>
);
};
export default AAPRegistration;

View file

@ -0,0 +1,18 @@
import React from 'react';
import { Form, Title } from '@patternfly/react-core';
import AAPRegistration from './components/AAPRegistration';
const AAPStep = () => {
return (
<Form>
<Title headingLevel='h1' size='xl'>
Ansible Automation Platform
</Title>
<AAPRegistration />
</Form>
);
};
export default AAPStep;

View file

@ -8,7 +8,10 @@ import {
Spinner,
} from '@patternfly/react-core';
import { useGetOscapCustomizationsQuery } from '../../../../../store/backendApi';
import {
useGetComplianceCustomizationsQuery,
useGetOscapCustomizationsQuery,
} from '../../../../../store/backendApi';
import { PolicyRead, usePolicyQuery } from '../../../../../store/complianceApi';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import { OpenScapProfile } from '../../../../../store/imageBuilderApi';
@ -16,6 +19,7 @@ import {
changeCompliance,
selectCompliancePolicyID,
selectComplianceProfileID,
selectComplianceType,
selectDistribution,
selectFips,
} from '../../../../../store/wizardSlice';
@ -31,12 +35,29 @@ export const OscapProfileInformation = ({
const release = useAppSelector(selectDistribution);
const compliancePolicyID = useAppSelector(selectCompliancePolicyID);
const complianceProfileID = useAppSelector(selectComplianceProfileID);
const complianceType = useAppSelector(selectComplianceType);
const fips = useAppSelector(selectFips);
const {
data: oscapPolicyInfo,
isFetching: isFetchingOscapPolicyInfo,
isSuccess: isSuccessOscapPolicyInfo,
error: policyError,
} = useGetComplianceCustomizationsQuery(
{
distribution: release,
policy: compliancePolicyID!,
},
{
skip: !compliancePolicyID || !!process.env.IS_ON_PREMISE,
},
);
const {
data: oscapProfileInfo,
isFetching: isFetchingOscapProfileInfo,
isSuccess: isSuccessOscapProfileInfo,
error: profileError,
} = useGetOscapCustomizationsQuery(
{
distribution: release,
@ -48,6 +69,20 @@ export const OscapProfileInformation = ({
},
);
const customizationData =
compliancePolicyID && oscapPolicyInfo ? oscapPolicyInfo : oscapProfileInfo;
const profileMetadata = oscapProfileInfo;
const isPolicyDataLoading = compliancePolicyID
? isFetchingOscapPolicyInfo
: false;
const isFetchingOscapData = isPolicyDataLoading || isFetchingOscapProfileInfo;
const isPolicyDataSuccess = compliancePolicyID
? isSuccessOscapPolicyInfo
: true;
const isSuccessOscapData = isPolicyDataSuccess && isSuccessOscapProfileInfo;
const hasCriticalError = profileError || (compliancePolicyID && policyError);
const shouldShowData = isSuccessOscapData && !hasCriticalError;
const {
data: policyInfo,
isFetching: isFetchingPolicyInfo,
@ -74,23 +109,28 @@ export const OscapProfileInformation = ({
policyTitle: pol.title,
}),
);
}, [isSuccessPolicyInfo]);
}, [isSuccessPolicyInfo, dispatch, policyInfo]);
const oscapProfile = oscapProfileInfo?.openscap as OpenScapProfile;
const oscapProfile = profileMetadata?.openscap as OpenScapProfile | undefined;
return (
<>
{(isFetchingOscapProfileInfo || isFetchingPolicyInfo) && (
<Spinner size='lg' />
{(isFetchingOscapData || isFetchingPolicyInfo) && <Spinner size='lg' />}
{hasCriticalError && (
<Content component={ContentVariants.p} className='pf-v6-u-color-200'>
Unable to load compliance information. Please try again.
</Content>
)}
{isSuccessOscapProfileInfo && (
{shouldShowData && (
<>
<Content component={ContentVariants.dl} className='review-step-dl'>
<Content
component={ContentVariants.dt}
className='pf-v6-u-min-width'
>
Profile description
{complianceType === 'compliance'
? 'Policy description'
: 'Profile description'}
</Content>
<Content component={ContentVariants.dd}>
{oscapProfile?.profile_description}
@ -116,7 +156,7 @@ export const OscapProfileInformation = ({
<Content component={ContentVariants.dd}>
<CodeBlock>
<CodeBlockCode>
{(oscapProfileInfo?.packages ?? []).join(', ')}
{(customizationData?.packages ?? []).join(', ')}
</CodeBlockCode>
</CodeBlock>
</Content>
@ -129,7 +169,7 @@ export const OscapProfileInformation = ({
<Content component={ContentVariants.dd}>
<CodeBlock>
<CodeBlockCode>
{oscapProfileInfo?.kernel?.append}
{customizationData?.kernel?.append}
</CodeBlockCode>
</CodeBlock>
</Content>
@ -142,7 +182,7 @@ export const OscapProfileInformation = ({
<Content component={ContentVariants.dd}>
<CodeBlock>
<CodeBlockCode>
{(oscapProfileInfo?.services?.enabled ?? []).join(' ')}
{(customizationData?.services?.enabled ?? []).join(' ')}
</CodeBlockCode>
</CodeBlock>
</Content>
@ -155,8 +195,8 @@ export const OscapProfileInformation = ({
<Content component={ContentVariants.dd}>
<CodeBlock>
<CodeBlockCode>
{(oscapProfileInfo?.services?.disabled ?? [])
.concat(oscapProfileInfo?.services?.masked ?? [])
{(customizationData?.services?.disabled ?? [])
.concat(customizationData?.services?.masked ?? [])
.join(' ')}
</CodeBlockCode>
</CodeBlock>

View file

@ -10,15 +10,15 @@ import {
import { useSelectorHandlers } from './useSelectorHandlers';
import {
useGetComplianceCustomizationsQuery,
useLazyGetComplianceCustomizationsQuery,
} from '../../../../../store/backendApi';
import {
PolicyRead,
usePoliciesQuery,
} from '../../../../../store/complianceApi';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import {
useGetOscapCustomizationsForPolicyQuery,
useLazyGetOscapCustomizationsForPolicyQuery,
} from '../../../../../store/imageBuilderApi';
import {
changeCompliance,
changeFileSystemConfigurationType,
@ -97,7 +97,7 @@ const PolicySelector = () => {
filter: `os_major_version=${majorVersion}`,
});
const { data: currentProfileData } = useGetOscapCustomizationsForPolicyQuery(
const { data: currentProfileData } = useGetComplianceCustomizationsQuery(
{
distribution: release,
policy: policyID!,
@ -105,7 +105,7 @@ const PolicySelector = () => {
{ skip: !policyID },
);
const [trigger] = useLazyGetOscapCustomizationsForPolicyQuery();
const [trigger] = useLazyGetComplianceCustomizationsQuery();
useEffect(() => {
if (!policies || policies.data === undefined) {

View file

@ -50,6 +50,7 @@ import {
Thead,
Tr,
} from '@patternfly/react-table';
import { orderBy } from 'lodash';
import { useDispatch } from 'react-redux';
import CustomHelperText from './components/CustomHelperText';
@ -66,7 +67,6 @@ import {
} from '../../../../constants';
import { useGetArchitecturesQuery } from '../../../../store/backendApi';
import {
ApiPackageSourcesResponse,
ApiRepositoryResponseRead,
ApiSearchRpmResponse,
useCreateRepositoryMutation,
@ -700,7 +700,7 @@ const Packages = () => {
);
}
const unpackedData: IBPackageWithRepositoryInfo[] =
let unpackedData: IBPackageWithRepositoryInfo[] =
combinedPackageData.flatMap((item) => {
// Spread modules into separate rows by application stream
if (item.sources) {
@ -724,13 +724,16 @@ const Packages = () => {
});
// group by name, but sort by application stream in descending order
unpackedData.sort((a, b) => {
if (a.name === b.name) {
return (b.stream ?? '').localeCompare(a.stream ?? '');
} else {
return a.name.localeCompare(b.name);
}
});
unpackedData = orderBy(
unpackedData,
[
'name',
(pkg) => pkg.stream || '',
(pkg) => pkg.repository || '',
(pkg) => pkg.module_name || '',
],
['asc', 'desc', 'asc', 'asc'],
);
if (toggleSelected === 'toggle-available') {
if (activeTabKey === Repos.INCLUDED) {
@ -866,8 +869,6 @@ const Packages = () => {
dispatch(addPackage(pkg));
if (pkg.type === 'module') {
setActiveStream(pkg.stream || '');
setActiveSortIndex(2);
setPage(1);
dispatch(
addModule({
name: pkg.module_name || '',
@ -993,7 +994,18 @@ const Packages = () => {
}
};
const initialExpandedPkgs: IBPackageWithRepositoryInfo[] = [];
const getPackageUniqueKey = (pkg: IBPackageWithRepositoryInfo): string => {
try {
if (!pkg || !pkg.name) {
return `invalid_${Date.now()}`;
}
return `${pkg.name}_${pkg.stream || 'none'}_${pkg.module_name || 'none'}_${pkg.repository || 'unknown'}`;
} catch {
return `error_${Date.now()}`;
}
};
const initialExpandedPkgs: string[] = [];
const [expandedPkgs, setExpandedPkgs] = useState(initialExpandedPkgs);
const setPkgExpanded = (
@ -1001,12 +1013,13 @@ const Packages = () => {
isExpanding: boolean,
) =>
setExpandedPkgs((prevExpanded) => {
const otherExpandedPkgs = prevExpanded.filter((p) => p.name !== pkg.name);
return isExpanding ? [...otherExpandedPkgs, pkg] : otherExpandedPkgs;
const pkgKey = getPackageUniqueKey(pkg);
const otherExpandedPkgs = prevExpanded.filter((key) => key !== pkgKey);
return isExpanding ? [...otherExpandedPkgs, pkgKey] : otherExpandedPkgs;
});
const isPkgExpanded = (pkg: IBPackageWithRepositoryInfo) =>
expandedPkgs.includes(pkg);
expandedPkgs.includes(getPackageUniqueKey(pkg));
const initialExpandedGroups: GroupWithRepositoryInfo['name'][] = [];
const [expandedGroups, setExpandedGroups] = useState(initialExpandedGroups);
@ -1030,51 +1043,37 @@ const Packages = () => {
'asc' | 'desc'
>('asc');
const getSortableRowValues = (
pkg: IBPackageWithRepositoryInfo,
): (string | number | ApiPackageSourcesResponse[] | undefined)[] => {
return [pkg.name, pkg.summary, pkg.stream, pkg.end_date, pkg.repository];
};
const sortedPackages = useMemo(() => {
if (!transformedPackages || !Array.isArray(transformedPackages)) {
return [];
}
let sortedPackages = transformedPackages;
sortedPackages = transformedPackages.sort((a, b) => {
const aValue = getSortableRowValues(a)[activeSortIndex];
const bValue = getSortableRowValues(b)[activeSortIndex];
if (typeof aValue === 'number') {
// Numeric sort
if (activeSortDirection === 'asc') {
return (aValue as number) - (bValue as number);
}
return (bValue as number) - (aValue as number);
}
// String sort
// if active stream is set, sort it to the top
if (aValue === activeStream) {
return -1;
}
if (bValue === activeStream) {
return 1;
}
if (activeSortDirection === 'asc') {
// handle packages with undefined stream
if (!aValue) {
return -1;
}
if (!bValue) {
return 1;
}
return (aValue as string).localeCompare(bValue as string);
} else {
// handle packages with undefined stream
if (!aValue) {
return 1;
}
if (!bValue) {
return -1;
}
return (bValue as string).localeCompare(aValue as string);
}
});
return orderBy(
transformedPackages,
[
// Active stream packages first (if activeStream is set)
(pkg) => (activeStream && pkg.stream === activeStream ? 0 : 1),
// Then by name
'name',
// Then by stream version (descending)
(pkg) => {
if (!pkg.stream) return '';
const parts = pkg.stream
.split('.')
.map((part) => parseInt(part, 10) || 0);
// Convert to string with zero-padding for proper sorting
return parts.map((p) => p.toString().padStart(10, '0')).join('.');
},
// Then by end date (nulls last)
(pkg) => pkg.end_date || '9999-12-31',
// Then by repository
(pkg) => pkg.repository || '',
// Finally by module name
(pkg) => pkg.module_name || '',
],
['asc', 'asc', 'desc', 'asc', 'asc', 'asc'],
);
}, [transformedPackages, activeStream]);
const getSortParams = (columnIndex: number) => ({
sortBy: {
@ -1100,14 +1099,14 @@ const Packages = () => {
(module) => module.name === pkg.name,
);
isSelected =
packages.some((p) => p.name === pkg.name) && !isModuleWithSameName;
packages.some((p) => p.name === pkg.name && p.stream === pkg.stream) &&
!isModuleWithSameName;
}
if (pkg.type === 'module') {
// the package is selected if it's added to the packages state
// and its module stream matches one in enabled_modules
// the package is selected if its module stream matches one in enabled_modules
isSelected =
packages.some((p) => p.name === pkg.name) &&
packages.some((p) => p.name === pkg.name && p.stream === pkg.stream) &&
modules.some(
(m) => m.name === pkg.module_name && m.stream === pkg.stream,
);
@ -1208,7 +1207,7 @@ const Packages = () => {
.slice(computeStart(), computeEnd())
.map((grp, rowIndex) => (
<Tbody
key={`${grp.name}-${rowIndex}`}
key={`${grp.name}-${grp.repository || 'default'}`}
isExpanded={isGroupExpanded(grp.name)}
>
<Tr data-testid='package-row'>
@ -1308,7 +1307,7 @@ const Packages = () => {
.slice(computeStart(), computeEnd())
.map((pkg, rowIndex) => (
<Tbody
key={`${pkg.name}-${rowIndex}`}
key={`${pkg.name}-${pkg.stream || 'default'}-${pkg.module_name || pkg.name}`}
isExpanded={isPkgExpanded(pkg)}
>
<Tr data-testid='package-row'>

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import {
ClipboardCopy,
@ -16,6 +16,7 @@ import ActivationKeysList from './components/ActivationKeysList';
import Registration from './components/Registration';
import SatelliteRegistration from './components/SatelliteRegistration';
import { useGetUser } from '../../../../Hooks';
import { useAppSelector } from '../../../../store/hooks';
import {
selectActivationKey,
@ -24,18 +25,7 @@ import {
const RegistrationStep = () => {
const { auth } = useChrome();
const [orgId, setOrgId] = useState<string | undefined>(undefined);
useEffect(() => {
(async () => {
const userData = await auth.getUser();
const id = userData?.identity?.internal?.org_id;
setOrgId(id);
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { orgId } = useGetUser(auth);
const activationKey = useAppSelector(selectActivationKey);
const registrationType = useAppSelector(selectRegistrationType);

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import {
Button,
@ -14,12 +14,12 @@ import {
Spinner,
} from '@patternfly/react-core';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { ChromeUser } from '@redhat-cloud-services/types';
import { AMPLITUDE_MODULE_NAME } from '../../../../../constants';
import {
useComposeBPWithNotification as useComposeBlueprintMutation,
useCreateBPWithNotification as useCreateBlueprintMutation,
useGetUser,
} from '../../../../../Hooks';
import { setBlueprintId } from '../../../../../store/BlueprintSlice';
import { CockpitCreateBlueprintRequest } from '../../../../../store/cockpit/types';
@ -44,19 +44,8 @@ export const CreateSaveAndBuildBtn = ({
setIsOpen,
isDisabled,
}: CreateDropdownProps) => {
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
const { analytics, auth, isBeta } = useChrome();
useEffect(() => {
(async () => {
const data = await auth.getUser();
setUserData(data);
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { userData } = useGetUser(auth);
const packages = useAppSelector(selectPackages);
@ -113,17 +102,7 @@ export const CreateSaveButton = ({
isDisabled,
}: CreateDropdownProps) => {
const { analytics, auth, isBeta } = useChrome();
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
useEffect(() => {
(async () => {
const data = await auth.getUser();
setUserData(data);
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { userData } = useGetUser(auth);
const packages = useAppSelector(selectPackages);

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import {
DropdownItem,
@ -9,11 +9,11 @@ import {
Spinner,
} from '@patternfly/react-core';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { ChromeUser } from '@redhat-cloud-services/types';
import { AMPLITUDE_MODULE_NAME } from '../../../../../constants';
import {
useComposeBPWithNotification as useComposeBlueprintMutation,
useGetUser,
useUpdateBPWithNotification as useUpdateBlueprintMutation,
} from '../../../../../Hooks';
import { CockpitCreateBlueprintRequest } from '../../../../../store/cockpit/types';
@ -37,19 +37,8 @@ export const EditSaveAndBuildBtn = ({
blueprintId,
isDisabled,
}: EditDropdownProps) => {
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
const { analytics, auth, isBeta } = useChrome();
useEffect(() => {
(async () => {
const data = await auth.getUser();
setUserData(data);
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { userData } = useGetUser(auth);
const { trigger: buildBlueprint } = useComposeBlueprintMutation();
const packages = useAppSelector(selectPackages);
@ -105,19 +94,8 @@ export const EditSaveButton = ({
blueprintId,
isDisabled,
}: EditDropdownProps) => {
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
const { analytics, auth, isBeta } = useChrome();
useEffect(() => {
(async () => {
const data = await auth.getUser();
setUserData(data);
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { userData } = useGetUser(auth);
const packages = useAppSelector(selectPackages);

View file

@ -18,6 +18,7 @@ import { EditSaveAndBuildBtn, EditSaveButton } from './EditDropdown';
import {
useCreateBPWithNotification as useCreateBlueprintMutation,
useGetUser,
useUpdateBPWithNotification as useUpdateBlueprintMutation,
} from '../../../../../Hooks';
import { resolveRelPath } from '../../../../../Utilities/path';
@ -33,6 +34,7 @@ const ReviewWizardFooter = () => {
const { isSuccess: isUpdateSuccess, reset: resetUpdate } =
useUpdateBlueprintMutation({ fixedCacheKey: 'updateBlueprintKey' });
const { auth } = useChrome();
const { orgId } = useGetUser(auth);
const { composeId } = useParams();
const [isOpen, setIsOpen] = useState(false);
const store = useStore();
@ -52,14 +54,12 @@ const ReviewWizardFooter = () => {
const getBlueprintPayload = async () => {
if (!process.env.IS_ON_PREMISE) {
const userData = await auth.getUser();
const orgId = userData?.identity?.internal?.org_id;
const requestBody = orgId && mapRequestFromState(store, orgId);
return requestBody;
}
// NOTE: This should be fine on-prem, we should
// be able to ignore the `org-id`
// NOTE: This is fine for on prem because we save the org id
// to state through a form field in the registration step
return mapRequestFromState(store, '');
};

View file

@ -25,6 +25,7 @@ import {
KernelList,
LocaleList,
OscapList,
RegisterAapList,
RegisterLaterList,
RegisterNowList,
RegisterSatelliteList,
@ -42,6 +43,7 @@ import isRhel from '../../../../../src/Utilities/isRhel';
import { targetOptions } from '../../../../constants';
import { useAppSelector } from '../../../../store/hooks';
import {
selectAapRegistration,
selectBlueprintDescription,
selectBlueprintName,
selectCompliancePolicyID,
@ -65,6 +67,7 @@ import { useHasSpecificTargetOnly } from '../../utilities/hasSpecificTargetOnly'
const Review = () => {
const { goToStepById } = useWizardContext();
const aapRegistration = useAppSelector(selectAapRegistration);
const blueprintName = useAppSelector(selectBlueprintName);
const blueprintDescription = useAppSelector(selectBlueprintDescription);
const distribution = useAppSelector(selectDistribution);
@ -83,6 +86,7 @@ const Review = () => {
const users = useAppSelector(selectUsers);
const kernel = useAppSelector(selectKernel);
const [isExpandedAap, setIsExpandedAap] = useState(true);
const [isExpandedImageOutput, setIsExpandedImageOutput] = useState(true);
const [isExpandedTargetEnvs, setIsExpandedTargetEnvs] = useState(true);
const [isExpandedFSC, setIsExpandedFSC] = useState(true);
@ -101,6 +105,8 @@ const Review = () => {
const [isExpandableFirstBoot, setIsExpandedFirstBoot] = useState(true);
const [isExpandedUsers, setIsExpandedUsers] = useState(true);
const onToggleAap = (isExpandedAap: boolean) =>
setIsExpandedAap(isExpandedAap);
const onToggleImageOutput = (isExpandedImageOutput: boolean) =>
setIsExpandedImageOutput(isExpandedImageOutput);
const onToggleTargetEnvs = (isExpandedTargetEnvs: boolean) =>
@ -499,6 +505,21 @@ const Review = () => {
<ServicesList />
</ExpandableSection>
)}
{aapRegistration.callbackUrl && (
<ExpandableSection
toggleContent={composeExpandable(
'Ansible Automation Platform',
'revisit-aap',
'wizard-aap',
)}
onToggle={(_event, isExpandableAap) => onToggleAap(isExpandableAap)}
isExpanded={isExpandedAap}
isIndented
data-testid='aap-expandable'
>
<RegisterAapList />
</ExpandableSection>
)}
{!process.env.IS_ON_PREMISE && (
<ExpandableSection
toggleContent={composeExpandable(

View file

@ -41,6 +41,10 @@ import { useAppSelector } from '../../../../store/hooks';
import { useGetSourceListQuery } from '../../../../store/provisioningApi';
import { useShowActivationKeyQuery } from '../../../../store/rhsmApi';
import {
selectAapCallbackUrl,
selectAapHostConfigKey,
selectAapTlsCertificateAuthority,
selectAapTlsConfirmation,
selectActivationKey,
selectArchitecture,
selectAwsAccountId,
@ -660,6 +664,45 @@ export const RegisterSatelliteList = () => {
);
};
export const RegisterAapList = () => {
const callbackUrl = useAppSelector(selectAapCallbackUrl);
const hostConfigKey = useAppSelector(selectAapHostConfigKey);
const tlsCertificateAuthority = useAppSelector(
selectAapTlsCertificateAuthority,
);
const skipTlsVerification = useAppSelector(selectAapTlsConfirmation);
const getTlsStatus = () => {
if (skipTlsVerification) {
return 'Insecure (TLS verification skipped)';
}
return tlsCertificateAuthority ? 'Configured' : 'None';
};
return (
<Content>
<Content component={ContentVariants.dl} className='review-step-dl'>
<Content component={ContentVariants.dt} className='pf-v6-u-min-width'>
Ansible Callback URL
</Content>
<Content component={ContentVariants.dd}>
{callbackUrl || 'None'}
</Content>
<Content component={ContentVariants.dt} className='pf-v6-u-min-width'>
Host Config Key
</Content>
<Content component={ContentVariants.dd}>
{hostConfigKey || 'None'}
</Content>
<Content component={ContentVariants.dt} className='pf-v6-u-min-width'>
TLS Certificate
</Content>
<Content component={ContentVariants.dd}>{getTlsStatus()}</Content>
</Content>
</Content>
);
};
export const RegisterNowList = () => {
const activationKey = useAppSelector(selectActivationKey);
const registrationType = useAppSelector(selectRegistrationType);

View file

@ -23,6 +23,7 @@ import {
CockpitUploadTypes,
} from '../../../store/cockpit/types';
import {
AapRegistration,
AwsUploadRequestOptions,
AzureUploadRequestOptions,
BlueprintExportResponse,
@ -49,6 +50,11 @@ import { ApiRepositoryImportResponseRead } from '../../../store/service/contentS
import {
ComplianceType,
initialState,
RegistrationType,
selectAapCallbackUrl,
selectAapHostConfigKey,
selectAapTlsCertificateAuthority,
selectAapTlsConfirmation,
selectActivationKey,
selectArchitecture,
selectAwsAccountId,
@ -205,8 +211,9 @@ function commonRequestToState(
snapshot_date = '';
}
// we need to check for the region for on-prem
const awsUploadOptions = aws?.upload_request
.options as AwsUploadRequestOptions;
.options as AwsUploadRequestOptions & { region?: string | undefined };
const gcpUploadOptions = gcp?.upload_request
.options as GcpUploadRequestOptions;
const azureUploadOptions = azure?.upload_request
@ -309,6 +316,7 @@ function commonRequestToState(
: 'manual') as AwsShareMethod,
source: { id: awsUploadOptions?.share_with_sources?.[0] },
sourceId: awsUploadOptions?.share_with_sources?.[0],
region: awsUploadOptions?.region,
},
snapshotting: {
useLatest: !snapshot_date && !request.image_requests[0]?.content_template,
@ -387,14 +395,7 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
baseUrl: request.customizations.subscription?.['base-url'] || '',
},
registration: {
registrationType:
request.customizations?.subscription && isRhel(request.distribution)
? request.customizations.subscription.rhc
? 'register-now-rhc'
: 'register-now-insights'
: getSatelliteCommand(request.customizations.files)
? 'register-satellite'
: 'register-later',
registrationType: getRegistrationType(request),
activationKey: isRhel(request.distribution)
? request.customizations.subscription?.['activation-key']
: undefined,
@ -403,6 +404,15 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
caCert: request.customizations.cacerts?.pem_certs[0],
},
},
aapRegistration: {
callbackUrl:
request.customizations?.aap_registration?.ansible_callback_url,
hostConfigKey: request.customizations?.aap_registration?.host_config_key,
tlsCertificateAuthority:
request.customizations?.aap_registration?.tls_certificate_authority,
skipTlsVerification:
request.customizations?.aap_registration?.skip_tls_verification,
},
...commonRequestToState(request),
};
};
@ -452,6 +462,15 @@ export const mapExportRequestToState = (
},
env: initialState.env,
registration: initialState.registration,
aapRegistration: {
callbackUrl:
request.customizations?.aap_registration?.ansible_callback_url,
hostConfigKey: request.customizations?.aap_registration?.host_config_key,
tlsCertificateAuthority:
request.customizations?.aap_registration?.tls_certificate_authority,
skipTlsVerification:
request.customizations?.aap_registration?.skip_tls_verification,
},
...commonRequestToState(blueprintResponse),
};
};
@ -461,6 +480,24 @@ const getFirstBootScript = (files?: File[]): string => {
return firstBootFile?.data ? atob(firstBootFile.data) : '';
};
const getAapRegistration = (state: RootState): AapRegistration | undefined => {
const callbackUrl = selectAapCallbackUrl(state);
const hostConfigKey = selectAapHostConfigKey(state);
const tlsCertificateAuthority = selectAapTlsCertificateAuthority(state);
const skipTlsVerification = selectAapTlsConfirmation(state);
if (!callbackUrl && !hostConfigKey && !tlsCertificateAuthority) {
return undefined;
}
return {
ansible_callback_url: callbackUrl || '',
host_config_key: hostConfigKey || '',
tls_certificate_authority: tlsCertificateAuthority || undefined,
skip_tls_verification: skipTlsVerification || undefined,
};
};
const getImageRequests = (
state: RootState,
): ImageRequest[] | CockpitImageRequest[] => {
@ -482,6 +519,24 @@ const getImageRequests = (
}));
};
const getRegistrationType = (request: BlueprintResponse): RegistrationType => {
const subscription = request.customizations.subscription;
const distribution = request.distribution;
const files = request.customizations.files;
if (subscription && isRhel(distribution)) {
if (subscription.rhc) {
return 'register-now-rhc';
} else {
return 'register-now-insights';
}
} else if (getSatelliteCommand(files)) {
return 'register-satellite';
} else {
return 'register-later';
}
};
const getSatelliteCommand = (files?: File[]): string => {
const satelliteCommandFile = files?.find(
(file) => file.path === SATELLITE_PATH,
@ -642,6 +697,7 @@ const getCustomizations = (state: RootState, orgID: string): Customizations => {
pem_certs: [satCert],
}
: undefined,
aap_registration: getAapRegistration(state),
};
};

View file

@ -11,6 +11,10 @@ import { useAppSelector } from '../../../store/hooks';
import { BlueprintsResponse } from '../../../store/imageBuilderApi';
import { useShowActivationKeyQuery } from '../../../store/rhsmApi';
import {
selectAapCallbackUrl,
selectAapHostConfigKey,
selectAapTlsCertificateAuthority,
selectAapTlsConfirmation,
selectActivationKey,
selectBlueprintDescription,
selectBlueprintId,
@ -54,6 +58,8 @@ import {
isSshKeyValid,
isUserGroupValid,
isUserNameValid,
isValidUrl,
validateMultipleCertificates,
} from '../validators';
export type StepValidation = {
@ -205,6 +211,62 @@ export function useRegistrationValidation(): StepValidation {
return { errors: {}, disabledNext: false };
}
export function useAAPValidation(): StepValidation {
const errors: Record<string, string> = {};
const callbackUrl = useAppSelector(selectAapCallbackUrl);
const hostConfigKey = useAppSelector(selectAapHostConfigKey);
const tlsCertificateAuthority = useAppSelector(
selectAapTlsCertificateAuthority,
);
const tlsConfirmation = useAppSelector(selectAapTlsConfirmation);
if (!callbackUrl && !hostConfigKey && !tlsCertificateAuthority) {
return { errors: {}, disabledNext: false };
}
if (!callbackUrl || callbackUrl.trim() === '') {
errors.callbackUrl = 'Ansible Callback URL is required';
} else if (!isValidUrl(callbackUrl)) {
errors.callbackUrl = 'Callback URL must be a valid URL';
}
if (!hostConfigKey || hostConfigKey.trim() === '') {
errors.hostConfigKey = 'Host Config Key is required';
}
if (tlsCertificateAuthority && tlsCertificateAuthority.trim() !== '') {
const validation = validateMultipleCertificates(tlsCertificateAuthority);
if (validation.errors.length > 0) {
errors.certificate = validation.errors.join(' ');
} else if (validation.validCertificates.length === 0) {
errors.certificate = 'No valid certificates found in the input.';
}
}
if (callbackUrl && callbackUrl.trim() !== '') {
const isHttpsUrl = callbackUrl.toLowerCase().startsWith('https://');
// If URL is HTTP, require TLS certificate
if (
!isHttpsUrl &&
(!tlsCertificateAuthority || tlsCertificateAuthority.trim() === '')
) {
errors.certificate = 'HTTP URL requires a custom TLS certificate';
return { errors, disabledNext: true };
}
// For HTTPS URL, if the TLS confirmation is not checked, require certificate
if (
!tlsConfirmation &&
(!tlsCertificateAuthority || tlsCertificateAuthority.trim() === '')
) {
errors.certificate =
'HTTPS URL requires either a custom TLS certificate or confirmation that no custom certificate is needed';
}
}
return { errors, disabledNext: Object.keys(errors).length > 0 };
}
export function useFilesystemValidation(): StepValidation {
const mode = useAppSelector(selectFileSystemConfigurationType);
const partitions = useAppSelector(selectPartitions);

View file

@ -138,3 +138,85 @@ export const isServiceValid = (service: string) => {
/[a-zA-Z]+/.test(service) // contains at least one letter
);
};
export const isValidUrl = (url: string): boolean => {
try {
const parsedUrl = new URL(url);
const isHttpOrHttps = ['http:', 'https:'].includes(parsedUrl.protocol);
const hostname = parsedUrl.hostname;
const hasValidDomain =
/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:\.[a-zA-Z]{2,})*$/.test(hostname);
return isHttpOrHttps && hasValidDomain;
} catch {
return false;
}
};
export const isValidCA = (ca: string) => {
if (!ca || typeof ca !== 'string') return false;
const trimmed = ca.trim();
const pemPattern =
/^-----BEGIN CERTIFICATE-----[\r\n]+([\s\S]*?)[\r\n]+-----END CERTIFICATE-----$/;
if (!pemPattern.test(trimmed)) {
return false;
}
const match = trimmed.match(pemPattern);
if (!match || !match[1]) {
return false;
}
const base64Content = match[1].replace(/[\r\n\s]/g, '');
const base64Pattern = /^[A-Za-z0-9+/]+(=*)$/;
return base64Pattern.test(base64Content) && base64Content.length > 0;
};
export const parseMultipleCertificates = (input: string): string[] => {
if (!input || typeof input !== 'string') return [];
const blockPattern =
/-----BEGIN CERTIFICATE-----[\s\S]*?(?=-----BEGIN CERTIFICATE-----|$)/g;
const matches = input.match(blockPattern);
return matches ? matches.map((m) => m.trim()) : [];
};
export const validateMultipleCertificates = (
input: string,
): {
certificates: string[];
validCertificates: string[];
invalidCertificates: string[];
errors: string[];
} => {
const certificates = parseMultipleCertificates(input);
const validCertificates: string[] = [];
const invalidCertificates: string[] = [];
const errors: string[] = [];
if (certificates.length === 0 && input.trim() !== '') {
errors.push(
'No valid certificate format found. Certificates must be in PEM/DER/CER format.',
);
return { certificates, validCertificates, invalidCertificates, errors };
}
certificates.forEach((cert, index) => {
if (isValidCA(cert)) {
validCertificates.push(cert);
} else {
invalidCertificates.push(cert);
errors.push(
`Certificate ${index + 1} is not valid. Must be in PEM/DER/CER format.`,
);
}
});
return { certificates, validCertificates, invalidCertificates, errors };
};

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import {
Alert,
@ -13,11 +13,11 @@ import {
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { ChromeUser } from '@redhat-cloud-services/types';
import ClonesTable from './ClonesTable';
import { AMPLITUDE_MODULE_NAME } from '../../constants';
import { useGetUser } from '../../Hooks';
import { useGetComposeStatusQuery } from '../../store/backendApi';
import { extractProvisioningList } from '../../store/helpers';
import {
@ -119,7 +119,7 @@ const AwsSourceName = ({ id }: AwsSourceNamePropTypes) => {
return <SourceNotFoundPopover />;
};
const parseGcpSharedWith = (
export const parseGcpSharedWith = (
sharedWith: GcpUploadRequestOptions['share_with_accounts'],
) => {
if (sharedWith) {
@ -134,19 +134,9 @@ type AwsDetailsPropTypes = {
export const AwsDetails = ({ compose }: AwsDetailsPropTypes) => {
const options = compose.request.image_requests[0].upload_request.options;
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
const { analytics, auth } = useChrome();
useEffect(() => {
(async () => {
const data = await auth.getUser();
setUserData(data);
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { userData } = useGetUser(auth);
if (!isAwsUploadRequestOptions(options)) {
throw TypeError(

View file

@ -25,7 +25,7 @@ import {
Tr,
} from '@patternfly/react-table';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { ChromeUser } from '@redhat-cloud-services/types';
import { useFlag } from '@unleash/proxy-client-react';
import { useDispatch } from 'react-redux';
import { NavigateFunction, useNavigate } from 'react-router-dom';
@ -58,6 +58,7 @@ import {
SEARCH_INPUT,
STATUS_POLLING_INTERVAL,
} from '../../constants';
import { useGetUser } from '../../Hooks';
import {
useGetBlueprintComposesQuery,
useGetBlueprintsQuery,
@ -87,11 +88,12 @@ import {
timestampToDisplayString,
timestampToDisplayStringDetailed,
} from '../../Utilities/time';
import { AzureLaunchModal } from '../Launch/AzureLaunchModal';
import { OciLaunchModal } from '../Launch/OciLaunchModal';
const ImagesTable = () => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
const selectedBlueprintId = useAppSelector(selectSelectedBlueprintId);
const blueprintSearchInput =
@ -104,16 +106,7 @@ const ImagesTable = () => {
const blueprintsLimit = useAppSelector(selectLimit) || PAGINATION_LIMIT;
const { analytics, auth } = useChrome();
useEffect(() => {
(async () => {
const data = await auth.getUser();
setUserData(data);
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { userData } = useGetUser(auth);
const searchParamsGetBlueprints: GetBlueprintsApiArg = {
limit: blueprintsLimit,
@ -382,8 +375,14 @@ type AzureRowPropTypes = {
};
const AzureRow = ({ compose, rowIndex }: AzureRowPropTypes) => {
const launchEofFlag = useFlag('image-builder.launcheof');
const details = <AzureDetails compose={compose} />;
const instance = <CloudInstance compose={compose} />;
const instance = launchEofFlag ? (
<AzureLaunchModal compose={compose} />
) : (
<CloudInstance compose={compose} />
);
const status = <CloudStatus compose={compose} />;
return (
@ -403,13 +402,18 @@ type OciRowPropTypes = {
};
const OciRow = ({ compose, rowIndex }: OciRowPropTypes) => {
const launchEofFlag = useFlag('image-builder.launcheof');
const daysToExpiration = Math.floor(
computeHoursToExpiration(compose.created_at) / 24,
);
const isExpired = daysToExpiration >= OCI_STORAGE_EXPIRATION_TIME_IN_DAYS;
const details = <OciDetails compose={compose} />;
const instance = <OciInstance compose={compose} isExpired={isExpired} />;
const instance = launchEofFlag ? (
<OciLaunchModal compose={compose} isExpired={isExpired} />
) : (
<OciInstance compose={compose} isExpired={isExpired} />
);
const status = (
<ExpiringStatus
compose={compose}
@ -467,18 +471,8 @@ type AwsRowPropTypes = {
const AwsRow = ({ compose, composeStatus, rowIndex }: AwsRowPropTypes) => {
const navigate = useNavigate();
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
const { analytics, auth } = useChrome();
useEffect(() => {
(async () => {
const data = await auth.getUser();
setUserData(data);
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { userData } = useGetUser(auth);
const target = <AwsTarget compose={compose} />;
const status = <CloudStatus compose={compose} />;
@ -553,18 +547,8 @@ const Row = ({
details,
instance,
}: RowPropTypes) => {
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
const { analytics, auth } = useChrome();
useEffect(() => {
(async () => {
const data = await auth.getUser();
setUserData(data);
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { userData } = useGetUser(auth);
const [isExpanded, setIsExpanded] = useState(false);
const handleToggle = () => setIsExpanded(!isExpanded);

View file

@ -1,4 +1,4 @@
import React, { Suspense, useEffect, useState } from 'react';
import React, { Suspense, useState } from 'react';
import path from 'path';
@ -20,7 +20,6 @@ import {
} from '@patternfly/react-core/dist/esm/components/List/List';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { ChromeUser } from '@redhat-cloud-services/types';
import { useLoadModule, useScalprum } from '@scalprum/react-core';
import cockpit from 'cockpit';
import { useNavigate } from 'react-router-dom';
@ -31,6 +30,7 @@ import {
MODAL_ANCHOR,
SEARCH_INPUT,
} from '../../constants';
import { useGetUser } from '../../Hooks';
import {
useGetBlueprintsQuery,
useGetComposeStatusQuery,
@ -54,7 +54,10 @@ import {
isOciUploadStatus,
} from '../../store/typeGuards';
import { resolveRelPath } from '../../Utilities/path';
import { useFlag } from '../../Utilities/useGetEnvironment';
import useProvisioningPermissions from '../../Utilities/useProvisioningPermissions';
import { AWSLaunchModal } from '../Launch/AWSLaunchModal';
import { GcpLaunchModal } from '../Launch/GcpLaunchModal';
type CloudInstancePropTypes = {
compose: ComposesResponseItem;
@ -97,21 +100,12 @@ const ProvisioningLink = ({
compose,
composeStatus,
}: ProvisioningLinkPropTypes) => {
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
const launchEofFlag = useFlag('image-builder.launcheof');
const { analytics, auth } = useChrome();
const { userData } = useGetUser(auth);
useEffect(() => {
(async () => {
const data = await auth.getUser();
setUserData(data);
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [wizardOpen, setWizardOpen] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [exposedScalprumModule, error] = useLoadModule(
{
scope: 'provisioning',
@ -182,7 +176,7 @@ const ProvisioningLink = ({
account_id: userData?.identity.internal?.account_id || 'Not found',
});
setWizardOpen(true);
setIsModalOpen(true);
}}
>
Launch
@ -202,6 +196,10 @@ const ProvisioningLink = ({
</Popover>
);
const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => {
setIsModalOpen(!isModalOpen);
};
return (
<>
<Suspense fallback='loading...'>
@ -209,7 +207,23 @@ const ProvisioningLink = ({
compose.blueprint_version !== selectedBlueprintVersion
? buttonWithTooltip
: btn}
{wizardOpen && (
{launchEofFlag && isModalOpen && provider === 'aws' && (
<AWSLaunchModal
isOpen={isModalOpen}
handleModalToggle={handleModalToggle}
compose={compose}
composeStatus={composeStatus}
/>
)}
{launchEofFlag && isModalOpen && provider === 'gcp' && (
<GcpLaunchModal
isOpen={isModalOpen}
handleModalToggle={handleModalToggle}
compose={compose}
composeStatus={composeStatus}
/>
)}
{!launchEofFlag && isModalOpen && (
<Modal
isOpen
appendTo={appendTo}
@ -218,7 +232,7 @@ const ProvisioningLink = ({
>
<ProvisioningWizard
hasAccess={permissions[provider]}
onClose={() => setWizardOpen(false)}
onClose={() => setIsModalOpen(false)}
image={{
name: compose.image_name || compose.id,
id: compose.id,

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import './ImageBuildStatus.scss';
import {
@ -24,13 +24,13 @@ import {
PendingIcon,
} from '@patternfly/react-icons';
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { ChromeUser } from '@redhat-cloud-services/types';
import {
AMPLITUDE_MODULE_NAME,
AWS_S3_EXPIRATION_TIME_IN_HOURS,
OCI_STORAGE_EXPIRATION_TIME_IN_DAYS,
} from '../../constants';
import { useGetUser } from '../../Hooks';
import { useGetComposeStatusQuery } from '../../store/backendApi';
import { CockpitComposesResponseItem } from '../../store/cockpit/types';
import {
@ -122,18 +122,8 @@ export const CloudStatus = ({ compose }: CloudStatusPropTypes) => {
const { data, isSuccess } = useGetComposeStatusQuery({
composeId: compose.id,
});
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
const { analytics, auth } = useChrome();
useEffect(() => {
(async () => {
const data = await auth.getUser();
setUserData(data);
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { userData } = useGetUser(auth);
if (!isSuccess) {
return <Skeleton />;

View file

@ -0,0 +1,104 @@
import React from 'react';
import {
Button,
List,
ListComponent,
ListItem,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
ModalVariant,
OrderType,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import {
ComposesResponseItem,
ComposeStatus,
} from '../../store/imageBuilderApi';
import { isAwsUploadRequestOptions } from '../../store/typeGuards';
type LaunchProps = {
isOpen: boolean;
handleModalToggle: (event: KeyboardEvent | React.MouseEvent) => void;
compose: ComposesResponseItem;
composeStatus: ComposeStatus | undefined;
};
export const AWSLaunchModal = ({
isOpen,
handleModalToggle,
compose,
composeStatus,
}: LaunchProps) => {
const options = compose.request.image_requests[0].upload_request.options;
if (!isAwsUploadRequestOptions(options)) {
throw TypeError(
`Error: options must be of type AwsUploadRequestOptions, not ${typeof options}.`,
);
}
const amiId =
composeStatus?.image_status.status === 'success' &&
composeStatus.image_status.upload_status?.options &&
'ami' in composeStatus.image_status.upload_status.options
? composeStatus.image_status.upload_status.options.ami
: '';
return (
<Modal
isOpen={isOpen}
onClose={handleModalToggle}
variant={ModalVariant.large}
aria-label='Open launch guide modal'
>
<ModalHeader
title={'Launch with Amazon Web Services'}
labelId='modal-title'
description={compose.image_name}
/>
<ModalBody id='modal-box-body-basic'>
<List component={ListComponent.ol} type={OrderType.number}>
<ListItem>
Navigate to the{' '}
<Button
component='a'
target='_blank'
variant='link'
icon={<ExternalLinkAltIcon />}
iconPosition='right'
href={`https://us-east-1.console.aws.amazon.com/ec2/home?region=us-east-1#ImageDetails:imageId=${amiId}`}
className='pf-v6-u-pl-0'
>
Images detail page
</Button>{' '}
located on your AWS console.
</ListItem>
<ListItem>
Copy the image to make it a permanent copy in your account.
<br />
Shared with Account{' '}
<span className='pf-v6-u-font-weight-bold'>
{options.share_with_accounts?.[0]}
</span>
<br />
AMI ID: <span className='pf-v6-u-font-weight-bold'>{amiId}</span>
</ListItem>
<ListItem>Launch image as an instance.</ListItem>
<ListItem>
Connect to it via SSH using the following username:{' '}
<span className='pf-v6-u-font-weight-bold'>ec2-user</span>
</ListItem>
</List>
</ModalBody>
<ModalFooter>
<Button key='close' variant='primary' onClick={handleModalToggle}>
Close
</Button>
</ModalFooter>
</Modal>
);
};

View file

@ -0,0 +1,116 @@
import React, { Fragment, useState } from 'react';
import {
Button,
List,
ListComponent,
ListItem,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
ModalVariant,
OrderType,
Skeleton,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import {
ComposesResponseItem,
useGetComposeStatusQuery,
} from '../../store/imageBuilderApi';
import { isAzureUploadStatus } from '../../store/typeGuards';
type LaunchProps = {
compose: ComposesResponseItem;
};
export const AzureLaunchModal = ({ compose }: LaunchProps) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const { data, isSuccess, isFetching } = useGetComposeStatusQuery({
composeId: compose.id,
});
if (!isSuccess) {
return <Skeleton />;
}
const options = data?.image_status.upload_status?.options;
if (options && !isAzureUploadStatus(options)) {
throw TypeError(
`Error: options must be of type AzureUploadStatus, not ${typeof options}.`,
);
}
const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => {
setIsModalOpen(!isModalOpen);
};
return (
<Fragment>
<Button
variant='link'
isInline
isDisabled={data?.image_status.status !== 'success'}
onClick={handleModalToggle}
>
Launch
</Button>
<Modal
isOpen={isModalOpen}
onClose={handleModalToggle}
variant={ModalVariant.large}
aria-label='Open launch guide wizard'
>
<ModalHeader
title={'Launch with Microsoft Azure'}
labelId='modal-title'
description={compose.image_name}
/>
<ModalBody id='modal-box-body-basic'>
<List component={ListComponent.ol} type={OrderType.number}>
<ListItem>
Locate{' '}
{!isFetching && (
<span className='pf-v6-u-font-weight-bold'>
{options?.image_name}{' '}
</span>
)}
{isFetching && <Skeleton />}
in the{' '}
<Button
component='a'
target='_blank'
variant='link'
icon={<ExternalLinkAltIcon />}
iconPosition='right'
href={`https://portal.azure.com/#view/Microsoft_Azure_ComputeHub/ComputeHubMenuBlade/~/imagesBrowse`}
className='pf-v6-u-pl-0'
>
Azure console
</Button>
.
</ListItem>
<ListItem>
Create a Virtual Machine (VM) by using the image.
<br />
Note: Review the{' '}
<span className='pf-v6-u-font-weight-bold'>
Availability Zone
</span>{' '}
and the <span className='pf-v6-u-font-weight-bold'>Size</span> to
meet your requirements. Adjust these settings as needed.
</ListItem>
</List>
</ModalBody>
<ModalFooter>
<Button key='close' variant='primary' onClick={handleModalToggle}>
Close
</Button>
</ModalFooter>
</Modal>
</Fragment>
);
};

View file

@ -0,0 +1,169 @@
import React, { useState } from 'react';
import {
Button,
ClipboardCopy,
ClipboardCopyVariant,
List,
ListComponent,
ListItem,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
ModalVariant,
OrderType,
TextInput,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import { generateDefaultName } from './useGenerateDefaultName';
import {
ComposesResponseItem,
ComposeStatus,
} from '../../store/imageBuilderApi';
import {
isGcpUploadRequestOptions,
isGcpUploadStatus,
} from '../../store/typeGuards';
import { parseGcpSharedWith } from '../ImagesTable/ImageDetails';
type LaunchProps = {
isOpen: boolean;
handleModalToggle: (event: KeyboardEvent | React.MouseEvent) => void;
compose: ComposesResponseItem;
composeStatus: ComposeStatus | undefined;
};
export const GcpLaunchModal = ({
isOpen,
handleModalToggle,
compose,
composeStatus,
}: LaunchProps) => {
const [customerProjectId, setCustomerProjectId] = useState('');
const statusOptions = composeStatus?.image_status.upload_status?.options;
const composeOptions =
compose.request.image_requests[0].upload_request.options;
if (
(statusOptions && !isGcpUploadStatus(statusOptions)) ||
!isGcpUploadRequestOptions(composeOptions)
) {
throw TypeError(
`Error: options must be of type GcpUploadRequestOptions, not ${typeof statusOptions}.`,
);
}
const imageName = statusOptions?.image_name;
const projectId = statusOptions?.project_id;
if (!imageName || !projectId) {
throw TypeError(
`Error: Image name not found, unable to generate a command to copy ${typeof statusOptions}.`,
);
}
const uniqueImageName = generateDefaultName(imageName);
const authorizeString =
composeOptions.share_with_accounts &&
composeOptions.share_with_accounts.length === 1
? `Authorize gcloud CLI to the following
account: ${parseGcpSharedWith(composeOptions.share_with_accounts)}.`
: composeOptions.share_with_accounts
? `Authorize gcloud CLI to use one of the following
accounts: ${parseGcpSharedWith(composeOptions.share_with_accounts)}.`
: 'Authorize gcloud CLI to use the account that the image is shared with.';
const installationCommand = `sudo dnf install google-cloud-cli`;
const createImage = `gcloud compute images create ${uniqueImageName} --source-image=${imageName} --source-image-project=${projectId} --project=${
customerProjectId || '<your_project_id>'
}`;
const createInstance = `gcloud compute instances create ${uniqueImageName} --image=${uniqueImageName} --project=${
customerProjectId || '<your_project_id>'
}`;
return (
<Modal
isOpen={isOpen}
onClose={handleModalToggle}
variant={ModalVariant.large}
aria-label='Open launch guide modal'
>
<ModalHeader
title={'Launch with Google Cloud Platform'}
labelId='modal-title'
description={compose.image_name}
/>
<ModalBody id='modal-box-body-basic'>
<List component={ListComponent.ol} type={OrderType.number}>
<ListItem>
Install the gcloud CLI. See the{' '}
<Button
component='a'
target='_blank'
variant='link'
icon={<ExternalLinkAltIcon />}
iconPosition='right'
href={`https://cloud.google.com/sdk/docs/install`}
className='pf-v6-u-pl-0'
>
Install gcloud CLI
</Button>
documentation.
<ClipboardCopy isReadOnly hoverTip='Copy' clickTip='Copied'>
{installationCommand}
</ClipboardCopy>
</ListItem>
<ListItem>{authorizeString}</ListItem>
<ListItem>
Enter your GCP project ID, and run the command to create the image
in your project.
<TextInput
className='pf-v6-u-mt-sm pf-v6-u-mb-md'
value={customerProjectId}
type='text'
onChange={(_event, value) => setCustomerProjectId(value)}
aria-label='Project ID input'
placeholder='Project ID'
/>
<ClipboardCopy
isReadOnly
hoverTip='Copy'
clickTip='Copied'
variant={ClipboardCopyVariant.expansion}
>
{createImage}
</ClipboardCopy>
</ListItem>
<ListItem>
Create an instance of your image by either accessing the{' '}
<Button
component='a'
target='_blank'
variant='link'
icon={<ExternalLinkAltIcon />}
iconPosition='right'
href={`https://console.cloud.google.com/compute/images`}
className='pf-v6-u-pl-0'
>
GCP console
</Button>{' '}
or by running the following command:
<ClipboardCopy
isReadOnly
hoverTip='Copy'
clickTip='Copied'
variant={ClipboardCopyVariant.expansion}
>
{createInstance}
</ClipboardCopy>
</ListItem>
</List>
</ModalBody>
<ModalFooter>
<Button key='close' variant='primary' onClick={handleModalToggle}>
Close
</Button>
</ModalFooter>
</Modal>
);
};

View file

@ -0,0 +1,139 @@
import React, { Fragment, useState } from 'react';
import {
Button,
ClipboardCopy,
ClipboardCopyVariant,
List,
ListComponent,
ListItem,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
ModalVariant,
OrderType,
Skeleton,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import { useNavigate } from 'react-router-dom';
import {
ComposesResponseItem,
useGetComposeStatusQuery,
} from '../../store/imageBuilderApi';
import { isOciUploadStatus } from '../../store/typeGuards';
import { resolveRelPath } from '../../Utilities/path';
type LaunchProps = {
isExpired: boolean;
compose: ComposesResponseItem;
};
export const OciLaunchModal = ({ isExpired, compose }: LaunchProps) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const { data, isSuccess, isFetching } = useGetComposeStatusQuery({
composeId: compose.id,
});
const navigate = useNavigate();
if (!isSuccess) {
return <Skeleton />;
}
const options = data?.image_status.upload_status?.options;
if (options && !isOciUploadStatus(options)) {
throw TypeError(
`Error: options must be of type OciUploadStatus, not ${typeof options}.`,
);
}
if (isExpired) {
return (
<Button
component='a'
target='_blank'
variant='link'
onClick={() => navigate(resolveRelPath(`imagewizard/${compose.id}`))}
isInline
>
Recreate image
</Button>
);
}
const handleModalToggle = () => {
setIsModalOpen(!isModalOpen);
};
return (
<Fragment>
<Button
variant='link'
isInline
isDisabled={data?.image_status.status !== 'success'}
onClick={handleModalToggle}
>
Image link
</Button>
<Modal
isOpen={isModalOpen}
onClose={handleModalToggle}
variant={ModalVariant.large}
aria-label='Open launch guide modal'
>
<ModalHeader
title={'Launch with Oracle Cloud Infrastructure'}
labelId='modal-title'
description={compose.image_name}
/>
<ModalBody id='modal-box-body-basic'>
<List component={ListComponent.ol} type={OrderType.number}>
<ListItem>
Navigate to the{' '}
<Button
component='a'
target='_blank'
variant='link'
icon={<ExternalLinkAltIcon />}
iconPosition='right'
href={`https://cloud.oracle.com/compute/images`}
className='pf-v6-u-pl-0'
>
Oracle Cloud&apos;s Custom Images
</Button>{' '}
page.
</ListItem>
<ListItem>
Select{' '}
<span className='pf-v6-u-font-weight-bold'>Import image</span>,
and enter the Object Storage URL of the image.
{!isFetching && (
<ClipboardCopy
isReadOnly
isExpanded
hoverTip='Copy'
clickTip='Copied'
variant={ClipboardCopyVariant.expansion}
>
{options?.url || ''}
</ClipboardCopy>
)}
{isFetching && <Skeleton />}
</ListItem>
<ListItem>
After the image is available, click on{' '}
<span className='pf-v6-u-font-weight-bold'>Create instance</span>.
</ListItem>
</List>
</ModalBody>
<ModalFooter>
<Button key='close' variant='primary' onClick={handleModalToggle}>
Close
</Button>
</ModalFooter>
</Modal>
</Fragment>
);
};

View file

@ -0,0 +1,34 @@
export const generateDefaultName = (imageName: string) => {
const date = new Date();
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear().toString();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const dateTimeString = `${month}${day}${year}-${hours}${minutes}`;
// gcloud images are valid in the form of: (?:[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?)
let newBlueprintName = imageName
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '');
if (!/^[a-z]/.test(newBlueprintName)) {
newBlueprintName = 'i' + newBlueprintName;
}
const maxLength = 63;
const uniquePartLength = dateTimeString.length + 1;
const baseNameMaxLength = maxLength - uniquePartLength;
if (newBlueprintName.length > baseNameMaxLength) {
newBlueprintName = newBlueprintName.substring(0, baseNameMaxLength);
}
while (newBlueprintName.endsWith('-')) {
newBlueprintName = newBlueprintName.slice(0, -1);
}
return `${newBlueprintName}-${dateTimeString}`;
};

View file

@ -156,7 +156,7 @@ const RegionsSelect = ({ composeId, handleClose }: RegionsSelectPropTypes) => {
<MenuToggle
variant='typeahead'
onClick={handleToggle}
innerRef={toggleRef}
ref={toggleRef}
isExpanded={isOpen}
>
<TextInputGroup isPlain>

View file

@ -4,3 +4,4 @@ export { useDeleteBPWithNotification } from './MutationNotifications/useDeleteBP
export { useFixupBPWithNotification } from './MutationNotifications/useFixupBPWithNotification';
export { useComposeBPWithNotification } from './MutationNotifications/useComposeBPWithNotification';
export { useCloneComposeWithNotification } from './MutationNotifications/useCloneComposeWithNotification';
export { useGetUser } from './useGetUser';

24
src/Hooks/useGetUser.tsx Normal file
View file

@ -0,0 +1,24 @@
import { useEffect, useState } from 'react';
import { ChromeUser } from '@redhat-cloud-services/types';
export const useGetUser = (auth: { getUser(): Promise<void | ChromeUser> }) => {
const [userData, setUserData] = useState<ChromeUser | void>(undefined);
const [orgId, setOrgId] = useState<string | undefined>(undefined);
useEffect(() => {
(async () => {
if (!process.env.IS_ON_PREMISE) {
const data = await auth.getUser();
const id = data?.identity.internal?.org_id;
setUserData(data);
setOrgId(id);
}
})();
// This useEffect hook should run *only* on mount and therefore has an empty
// dependency array. eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { userData, orgId };
};

View file

@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.10.4'
const PACKAGE_VERSION = '2.10.5'
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

View file

@ -43,6 +43,12 @@ export const useLazyGetOscapCustomizationsQuery = process.env.IS_ON_PREMISE
? cockpitQueries.useLazyGetOscapCustomizationsQuery
: serviceQueries.useLazyGetOscapCustomizationsQuery;
export const useGetComplianceCustomizationsQuery =
serviceQueries.useGetOscapCustomizationsForPolicyQuery;
export const useLazyGetComplianceCustomizationsQuery =
serviceQueries.useLazyGetOscapCustomizationsForPolicyQuery;
export const useComposeBlueprintMutation = process.env.IS_ON_PREMISE
? cockpitQueries.useComposeBlueprintMutation
: serviceQueries.useComposeBlueprintMutation;

View file

@ -44,7 +44,8 @@ export type RegistrationType =
| 'register-now'
| 'register-now-insights'
| 'register-now-rhc'
| 'register-satellite';
| 'register-satellite'
| 'register-aap';
export type ComplianceType = 'openscap' | 'compliance';
@ -89,6 +90,12 @@ export type wizardState = {
architecture: ImageRequest['architecture'];
distribution: Distributions;
imageTypes: ImageTypes[];
aapRegistration: {
callbackUrl: string | undefined;
hostConfigKey: string | undefined;
tlsCertificateAuthority: string | undefined;
skipTlsVerification: boolean | undefined;
};
aws: {
accountId: string;
shareMethod: AwsShareMethod;
@ -189,6 +196,12 @@ export const initialState: wizardState = {
architecture: X86_64,
distribution: RHEL_10,
imageTypes: [],
aapRegistration: {
callbackUrl: undefined,
hostConfigKey: undefined,
tlsCertificateAuthority: undefined,
skipTlsVerification: undefined,
},
aws: {
accountId: '',
shareMethod: 'sources',
@ -376,6 +389,26 @@ export const selectSatelliteCaCertificate = (state: RootState) => {
return state.wizard.registration.satelliteRegistration.caCert;
};
export const selectAapRegistration = (state: RootState) => {
return state.wizard.aapRegistration;
};
export const selectAapCallbackUrl = (state: RootState) => {
return state.wizard.aapRegistration?.callbackUrl;
};
export const selectAapHostConfigKey = (state: RootState) => {
return state.wizard.aapRegistration?.hostConfigKey;
};
export const selectAapTlsCertificateAuthority = (state: RootState) => {
return state.wizard.aapRegistration?.tlsCertificateAuthority;
};
export const selectAapTlsConfirmation = (state: RootState) => {
return state.wizard.aapRegistration?.skipTlsVerification;
};
export const selectComplianceProfileID = (state: RootState) => {
return state.wizard.compliance.profileID;
};
@ -627,6 +660,22 @@ export const wizardSlice = createSlice({
changeSatelliteCaCertificate: (state, action: PayloadAction<string>) => {
state.registration.satelliteRegistration.caCert = action.payload;
},
changeAapCallbackUrl: (state, action: PayloadAction<string>) => {
state.aapRegistration.callbackUrl = action.payload;
},
changeAapHostConfigKey: (state, action: PayloadAction<string>) => {
state.aapRegistration.hostConfigKey = action.payload;
},
changeAapTlsCertificateAuthority: (
state,
action: PayloadAction<string>,
) => {
state.aapRegistration.tlsCertificateAuthority = action.payload;
},
changeAapTlsConfirmation: (state, action: PayloadAction<boolean>) => {
state.aapRegistration.skipTlsVerification = action.payload;
},
changeActivationKey: (
state,
action: PayloadAction<ActivationKeys['name']>,
@ -1230,6 +1279,10 @@ export const {
changeTimezone,
changeSatelliteRegistrationCommand,
changeSatelliteCaCertificate,
changeAapCallbackUrl,
changeAapHostConfigKey,
changeAapTlsCertificateAuthority,
changeAapTlsConfirmation,
addNtpServer,
removeNtpServer,
changeHostname,

View file

@ -714,6 +714,9 @@ describe('Import modal', () => {
),
);
// AAP
await clickNext();
// Firstboot
await clickNext();
expect(

View file

@ -24,6 +24,8 @@ vi.mock('@unleash/proxy-client-react', () => ({
switch (flag) {
case 'image-builder.compliance.enabled':
return true;
case 'image-builder.aap.enabled':
return true;
default:
return false;
}

View file

@ -332,4 +332,30 @@ describe('OpenSCAP edit mode', () => {
user.click(selectedBtn);
await screen.findByText('neovim');
});
test('customized policy shows only non-removed rules', async () => {
const { oscapCustomizations, oscapCustomizationsPolicy } = await import(
'../../../../fixtures/oscap'
);
const profileId = 'xccdf_org.ssgproject.content_profile_cis_workstation_l1';
const normalProfile = oscapCustomizations(profileId);
expect(normalProfile.packages).toEqual(['aide', 'neovim']);
const customPolicy = oscapCustomizationsPolicy('custom-policy-123');
expect(customPolicy.packages).toEqual(['neovim']);
await renderCreateMode();
await selectRhel9();
await selectGuestImageTarget();
await goToOscapStep();
await selectProfile();
await waitFor(() => {
expect(screen.getByText(/aide, neovim/i)).toBeInTheDocument();
});
expect(customPolicy.packages).not.toContain('aide');
expect(customPolicy.packages).toContain('neovim');
expect(normalProfile.packages).toContain('aide');
expect(normalProfile.packages).toContain('neovim');
});
});

View file

@ -513,6 +513,123 @@ describe('Step Packages', () => {
expect(secondAppStreamRow).toBeDisabled();
expect(secondAppStreamRow).not.toBeChecked();
});
test('module selection sorts selected stream to top while maintaining alphabetical order', async () => {
const user = userEvent.setup();
await renderCreateMode();
await goToPackagesStep();
await typeIntoSearchBox('sortingTest');
await screen.findAllByText('alphaModule');
await screen.findAllByText('betaModule');
await screen.findAllByText('gammaModule');
let rows = await screen.findAllByRole('row');
rows.shift();
expect(rows).toHaveLength(6);
expect(rows[0]).toHaveTextContent('alphaModule');
expect(rows[0]).toHaveTextContent('3.0');
expect(rows[1]).toHaveTextContent('alphaModule');
expect(rows[1]).toHaveTextContent('2.0');
expect(rows[2]).toHaveTextContent('betaModule');
expect(rows[2]).toHaveTextContent('4.0');
expect(rows[3]).toHaveTextContent('betaModule');
expect(rows[3]).toHaveTextContent('2.0');
// Select betaModule with stream 2.0 (row index 3)
const betaModule20Checkbox = await screen.findByRole('checkbox', {
name: /select row 3/i,
});
await waitFor(() => user.click(betaModule20Checkbox));
expect(betaModule20Checkbox).toBeChecked();
// After selection, the active stream (2.0) should be prioritized
// All modules with stream 2.0 should move to the top, maintaining alphabetical order
rows = await screen.findAllByRole('row');
rows.shift();
expect(rows[0]).toHaveTextContent('alphaModule');
expect(rows[0]).toHaveTextContent('2.0');
expect(rows[1]).toHaveTextContent('betaModule');
expect(rows[1]).toHaveTextContent('2.0');
expect(rows[2]).toHaveTextContent('gammaModule');
expect(rows[2]).toHaveTextContent('2.0');
expect(rows[3]).toHaveTextContent('alphaModule');
expect(rows[3]).toHaveTextContent('3.0');
expect(rows[4]).toHaveTextContent('betaModule');
expect(rows[4]).toHaveTextContent('4.0');
expect(rows[5]).toHaveTextContent('gammaModule');
expect(rows[5]).toHaveTextContent('1.5');
// Verify that only the selected module is checked
const updatedBetaModule20Checkbox = await screen.findByRole('checkbox', {
name: /select row 1/i, // betaModule 2.0 is now at position 1
});
expect(updatedBetaModule20Checkbox).toBeChecked();
// Verify that only one checkbox is checked
const allCheckboxes = await screen.findAllByRole('checkbox', {
name: /select row [0-9]/i,
});
const checkedCheckboxes = allCheckboxes.filter(
(cb) => (cb as HTMLInputElement).checked,
);
expect(checkedCheckboxes).toHaveLength(1);
expect(checkedCheckboxes[0]).toBe(updatedBetaModule20Checkbox);
});
test('unselecting a module does not cause jumping but may reset sort to default', async () => {
const user = userEvent.setup();
await renderCreateMode();
await goToPackagesStep();
await selectCustomRepo();
await typeIntoSearchBox('sortingTest');
await screen.findAllByText('betaModule');
const betaModule20Checkbox = await screen.findByRole('checkbox', {
name: /select row 3/i,
});
await waitFor(() => user.click(betaModule20Checkbox));
expect(betaModule20Checkbox).toBeChecked();
let rows = await screen.findAllByRole('row');
rows.shift();
expect(rows[0]).toHaveTextContent('alphaModule');
expect(rows[0]).toHaveTextContent('2.0');
expect(rows[1]).toHaveTextContent('betaModule');
expect(rows[1]).toHaveTextContent('2.0');
const updatedBetaModule20Checkbox = await screen.findByRole('checkbox', {
name: /select row 1/i,
});
await waitFor(() => user.click(updatedBetaModule20Checkbox));
expect(updatedBetaModule20Checkbox).not.toBeChecked();
// After unselection, the sort may reset to default or stay the same
// The important thing is that we don't get jumping/reordering during the interaction
rows = await screen.findAllByRole('row');
rows.shift(); // Remove header row
const allCheckboxes = await screen.findAllByRole('checkbox', {
name: /select row [0-9]/i,
});
const checkedCheckboxes = allCheckboxes.filter(
(cb) => (cb as HTMLInputElement).checked,
);
expect(checkedCheckboxes).toHaveLength(0);
// The key test: the table should have a consistent, predictable order
// Either the original alphabetical order OR the stream-sorted order
// What we don't want is jumping around during the selection/unselection process
expect(rows).toHaveLength(6); // Still have all 6 modules
const moduleNames = rows.map((row) => {
const match = row.textContent?.match(/(\w+Module)/);
return match ? match[1] : '';
});
expect(moduleNames).toContain('alphaModule');
expect(moduleNames).toContain('betaModule');
expect(moduleNames).toContain('gammaModule');
});
});
});

View file

@ -109,12 +109,12 @@ describe('Step Services', () => {
router = undefined;
});
test('clicking Next loads First boot script', async () => {
test('clicking Next loads Ansible Automation Platform', async () => {
await renderCreateMode();
await goToServicesStep();
await clickNext();
await screen.findByRole('heading', {
name: 'First boot configuration',
name: 'Ansible Automation Platform',
});
});

View file

@ -36,8 +36,20 @@ export const mockPolicies = {
profile_title: 'DISA STIG with GUI for Red Hat Enterprise Linux 8',
ref_id: 'xccdf_org.ssgproject.content_profile_stig_gui',
},
{
id: 'custom-policy-123',
title: 'Custom CIS Policy (Partial Rules)',
description: 'A customized policy where user removed some rules',
compliance_threshold: 100,
total_system_count: 5,
type: 'policy',
os_major_version: 8,
profile_title:
'Custom CIS Red Hat Enterprise Linux 8 Benchmark for Level 1 - Workstation',
ref_id: 'xccdf_org.ssgproject.content_profile_cis_workstation_l1',
},
],
meta: {
total: 3,
total: 4,
},
};

View file

@ -124,9 +124,17 @@ export const oscapCustomizationsPolicy = (
): GetOscapCustomizationsApiResponse => {
const policyData = mockPolicies.data.find((p) => p.id === policy);
const customizations = oscapCustomizations(policyData!.ref_id);
// filter out a single package to simulate the customizations being tailored
customizations.packages = customizations.packages!.filter(
(p) => p !== 'aide',
);
// Simulate different levels of customization based on policy
if (policy === 'custom-policy-123') {
// This policy has user-customized rules - only neovim remains
customizations.packages = ['neovim']; // User removed aide package
} else {
// Other policies: filter out a single package to simulate basic customizations
customizations.packages = customizations.packages!.filter(
(p) => p !== 'aide',
);
}
return customizations;
};

View file

@ -75,6 +75,64 @@ export const mockSourcesPackagesResults = (
},
];
}
if (search === 'sortingTest') {
return [
{
package_name: 'alphaModule',
summary: 'Alpha module for sorting tests',
package_sources: [
{
name: 'alphaModule',
type: 'module',
stream: '2.0',
end_date: '2025-12-01',
},
{
name: 'alphaModule',
type: 'module',
stream: '3.0',
end_date: '2027-12-01',
},
],
},
{
package_name: 'betaModule',
summary: 'Beta module for sorting tests',
package_sources: [
{
name: 'betaModule',
type: 'module',
stream: '2.0',
end_date: '2025-06-01',
},
{
name: 'betaModule',
type: 'module',
stream: '4.0',
end_date: '2028-06-01',
},
],
},
{
package_name: 'gammaModule',
summary: 'Gamma module for sorting tests',
package_sources: [
{
name: 'gammaModule',
type: 'module',
stream: '2.0',
end_date: '2025-08-01',
},
{
name: 'gammaModule',
type: 'module',
stream: '1.5',
end_date: '2026-08-01',
},
],
},
];
}
if (search === 'mock') {
return [
{

View file

@ -64,6 +64,8 @@ vi.mock('@unleash/proxy-client-react', () => ({
return true;
case 'image-builder.templates.enabled':
return true;
case 'image-builder.aap.enabled':
return true;
default:
return false;
}