diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 3df5fda..2821fb1 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,15 +1,16 @@ # Copyright 2024 Defense Unicorns # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial -name: Scan +name: Lint on: # This workflow is triggered on pull requests to the main branch. pull_request: - # milestoned is added here as a workaround for release-please not triggering PR workflows (PRs should be added to a milestone to trigger the workflow). - types: [milestoned, opened, reopened, synchronize] + branches: [main] + # milestoned is added here so that a PR can be re-triggered if it is milestoned. + types: [milestoned, opened, edited, synchronize] jobs: - validate: - uses: defenseunicorns/uds-common/.github/workflows/callable-lint.yaml@7826099a1ceb4657f9cd502968dea8e0e7753ac6 # v1.7.0 + run: + uses: defenseunicorns/uds-common/.github/workflows/callable-lint.yaml@664946ed5f6a5fe6a19f4ba6fcdc909981aefbe4 # v1.6.2 secrets: inherit diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0957816..8a61507 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,16 +17,21 @@ permissions: jobs: publish: permissions: - contents: write # Allows reading the content of the repository. - packages: write # Allows reading the content of the repository's packages. + contents: write + packages: write id-token: write strategy: matrix: - flavor: [upstream, registry1] - architecture: [amd64] - uses: defenseunicorns/uds-common/.github/workflows/callable-publish.yaml@7826099a1ceb4657f9cd502968dea8e0e7753ac6 # v1.7.0 + flavor: [upstream, registry1, unicorn] + architecture: [amd64, arm64] + exclude: + - flavor: upstream + architecture: arm64 + - flavor: unicorn + architecture: arm64 + uses: defenseunicorns/uds-common/.github/workflows/callable-publish.yaml@664946ed5f6a5fe6a19f4ba6fcdc909981aefbe4 # v1.6.2 with: flavor: ${{ matrix.flavor }} - runsOn: uds-marketplace-ubuntu-big-boy-8-core + runsOn: ${{ matrix.architecture == 'arm64' && 'uds-marketplace-ubuntu-arm64-4-core' || 'uds-marketplace-ubuntu-big-boy-4-core' }} uds-releaser: true secrets: inherit # Inherits all secrets from the parent workflow. diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml deleted file mode 100644 index e3b2ab1..0000000 --- a/.github/workflows/scorecard.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright 2024 Defense Unicorns -# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial - -name: Scorecards supply-chain security -on: - # Only the default branch is supported. - branch_protection_rule: - schedule: - - cron: '30 1 * * 6' - push: - branches: ["main"] - -# Declare default permissions as read only. -permissions: read-all - -jobs: - validate: - permissions: - # Needed to upload the results to code-scanning dashboard. - security-events: write - # Used to receive a badge. - id-token: write - uses: defenseunicorns/uds-common/.github/workflows/callable-scorecard.yaml@7826099a1ceb4657f9cd502968dea8e0e7753ac6 # v1.7.0 - secrets: inherit diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 98b41fa..c8096f4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,48 +3,48 @@ name: Test -# This workflow is triggered on pull requests to the main branch. on: + # This workflow is triggered on pull requests to the main branch. pull_request: - branches: [main] - types: [milestoned, opened, synchronize] + # milestoned is added here so that a PR can be re-triggered if it is milestoned. + types: [milestoned, opened, reopened, synchronize] paths-ignore: - "**.md" - "**.jpg" - "**.png" - "**.gif" - "**.svg" - - "adr/**" - - "docs/**" - - ".gitignore" - - "renovate.json" - - ".release-please-config.json" - - "release-please-config.json" - - "oscal-component.yaml" - - "CODEOWNERS" - - "LICENSE" - - "CONTRIBUTING.md" - - "SECURITY.md" + - adr/** + - docs/** + - .gitignore + - renovate.json + - .release-please-config.json + - release-please-config.json + - CODEOWNERS + - LICENSE + - CONTRIBUTING.md + - SECURITY.md + +# Permissions for the GITHUB_TOKEN used by the workflow. +permissions: + contents: read # Allows reading the content of the repository. + packages: read # Allows reading the content of the repository's packages. + id-token: write # Abort prior jobs in the same workflow / PR concurrency: group: test-${{ github.ref }} cancel-in-progress: true -permissions: - contents: read - id-token: write - packages: read - jobs: check-flavor: - runs-on: uds-marketplace-ubuntu-big-boy-8-core + runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: test-flavor - uses: defenseunicorns/uds-common/.github/actions/test-flavor@7826099a1ceb4657f9cd502968dea8e0e7753ac6 # v1.7.0 + uses: defenseunicorns/uds-common/.github/actions/test-flavor@664946ed5f6a5fe6a19f4ba6fcdc909981aefbe4 # v1.6.2 id: test-flavor outputs: upgrade-flavors: ${{ steps.test-flavor.outputs.upgrade-flavors }} @@ -55,12 +55,12 @@ jobs: fail-fast: false matrix: type: [install, upgrade] - flavor: [upstream, registry1] - uses: defenseunicorns/uds-common/.github/workflows/callable-test.yaml@7826099a1ceb4657f9cd502968dea8e0e7753ac6 # v1.7.0 + flavor: [upstream, unicorn] + uses: defenseunicorns/uds-common/.github/workflows/callable-test.yaml@664946ed5f6a5fe6a19f4ba6fcdc909981aefbe4 # v1.6.2 with: + timeout: 30 runsOn: uds-marketplace-ubuntu-big-boy-8-core upgrade-flavors: ${{ needs.check-flavor.outputs.upgrade-flavors }} flavor: ${{ matrix.flavor }} type: ${{ matrix.type }} - reports-path: "tests/.playwright/reports/" secrets: inherit # Inherits all secrets from the parent workflow. diff --git a/.gitignore b/.gitignore index 0099811..f08ef09 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ zarf-sbom tmp/ values-*.yaml overlay-values-* +.tool-versions # Terraform test/tf/public-ec2-instance/.test-data @@ -26,3 +27,12 @@ test/tf/public-ec2-instance/.terraform terraform.tfstate terraform.tfstate.backup .terraform.lock.hcl + +# Tests +node_modules/ +.playwright/ +tests/*.png + +# VSCode +.vscode/ + diff --git a/.release-please-manifest.json b/.release-please-manifest.json deleted file mode 100644 index fe7dd5b..0000000 --- a/.release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "10.3.0-uds.0" -} diff --git a/README.md b/README.md index 1164d59..e435ad3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This package is designed to be deployed on [UDS Core](https://github.com/defense > Jira software is a popular project management tool used by teams to plan, track, and manage their work. It offers features such as issue tracking, agile project management, and customizable workflows, making it a versatile solution for software development and other project-based teams. -## Pre-requisites +## Prerequisites The Jira Package expects to be deployed on top of [UDS Core](https://github.com/defenseunicorns/uds-core) with the dependencies listed below being configured prior to deployment. diff --git a/chart/templates/jira-admin.yaml b/chart/templates/jira-admin.yaml new file mode 100644 index 0000000..1631e16 --- /dev/null +++ b/chart/templates/jira-admin.yaml @@ -0,0 +1,13 @@ +# Copyright 2025 Defense Unicorns +# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +apiVersion: v1 +kind: Secret +metadata: + name: jira-admin +type: Opaque +data: + username: {{ .Values.setup.admin.username | b64enc }} + password: {{ .Values.setup.admin.password | b64enc }} + email: {{ .Values.setup.admin.email | b64enc }} + fullname: {{ .Values.setup.admin.fullname | b64enc }} \ No newline at end of file diff --git a/chart/templates/jira-python-exemption.yaml b/chart/templates/jira-python-exemption.yaml new file mode 100644 index 0000000..816b266 --- /dev/null +++ b/chart/templates/jira-python-exemption.yaml @@ -0,0 +1,20 @@ +# Copyright 2024 Defense Unicorns +# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +apiVersion: uds.dev/v1alpha1 +kind: Exemption +metadata: + name: "jira-setup-exemption" + namespace: uds-policy-exemptions +spec: + exemptions: + - description: Allow Jira setup job to run as root for Python package installation + matcher: + kind: pod + name: ^jira-setup-.* + namespace: jira + policies: + - RequireNonRootUser + - DropAllCapabilities + - RestrictCapabilities + - DisallowPrivileged \ No newline at end of file diff --git a/chart/templates/jira-setup-configmap.yaml b/chart/templates/jira-setup-configmap.yaml new file mode 100644 index 0000000..fa8b8f4 --- /dev/null +++ b/chart/templates/jira-setup-configmap.yaml @@ -0,0 +1,402 @@ +# Copyright 2025 Defense Unicorns +# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +apiVersion: v1 +kind: ConfigMap +metadata: + name: jira-setup-script +data: + test_license.py: | + #!/usr/bin/env python3 + + import requests + from requests.exceptions import RequestException + import os + import sys + import time + import json + import logging + from urllib3.exceptions import InsecureRequestWarning + from typing import Optional, Dict, Any + from bs4 import BeautifulSoup + + # Configure logging + logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + logger = logging.getLogger(__name__) + + # Suppress only the single warning from urllib3 needed. + requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning) + + class Config: + JIRA_DOMAIN = os.getenv("JIRA_DOMAIN", "") + JIRA_ADMIN_USERNAME = os.getenv("JIRA_ADMIN_USERNAME", "") + JIRA_ADMIN_PASSWORD = os.getenv("JIRA_ADMIN_PASSWORD", "") + JIRA_ADMIN_EMAIL = os.getenv("JIRA_ADMIN_EMAIL", "") + JIRA_ADMIN_FULLNAME = os.getenv("JIRA_ADMIN_FULLNAME", "") + JIRA_LICENSE_KEY = os.getenv("JIRA_LICENSE_KEY", "") + + MAX_RETRIES = 30 + RETRY_INTERVAL = 10 + TIMEOUT = 30 + MAX_INIT_WAIT = 300 # 5 minutes + INIT_CHECK_INTERVAL = 5 # 5 seconds + + @property + def base_url(self) -> str: + return f"https://{self.JIRA_DOMAIN}" + + @property + def urls(self) -> Dict[str, str]: + return { + 'status': f"{self.base_url}/status", + 'app_properties': f"{self.base_url}/secure/SetupApplicationProperties!default.jspa", + 'setup_properties': f"{self.base_url}/secure/SetupApplicationProperties.jspa", + 'license': f"{self.base_url}/secure/SetupLicense!default.jspa", + 'setup_license': f"{self.base_url}/secure/SetupLicense.jspa", + 'admin': f"{self.base_url}/secure/SetupAdminAccount!default.jspa", + 'setup_admin': f"{self.base_url}/secure/SetupAdminAccount.jspa", + 'mail': f"{self.base_url}/secure/SetupMailNotifications!default.jspa", + 'setup_mail': f"{self.base_url}/secure/SetupMailNotifications.jspa" + } + + class JiraSetup: + def __init__(self): + self.config = Config() + self.session = requests.Session() + self.session.headers.update({ + "Content-Type": "application/x-www-form-urlencoded", + "Origin": self.config.base_url, + "Referer": f"{self.config.base_url}/" + }) + # Disable SSL verification for development environments + self.session.verify = False + + def _make_request( + self, + method: str, + url: str, + data: Optional[Dict[str, str]] = None, + expected_status_codes: tuple = (200, 302), + long_timeout: bool = False + ) -> requests.Response: + """Make HTTP request with proper error handling and logging.""" + try: + timeout = self.config.TIMEOUT if not long_timeout else self.config.MAX_INIT_WAIT + response = self.session.request( + method=method, + url=url, + data=data, + timeout=timeout, + allow_redirects=True + ) + + logger.debug(f"Request to {url}: {response.status_code}") + logger.debug(f"Response headers: {dict(response.headers)}") + + if response.status_code not in expected_status_codes: + logger.error(f"Unexpected status code: {response.status_code}") + logger.error(f"Response content: {response.text}") + raise RequestException(f"Unexpected status code: {response.status_code}") + + return response + + except RequestException as e: + logger.error(f"Request failed: {str(e)}") + raise + + def _extract_form_token(self, html_content: str) -> str: + """Extract XSRF token from HTML form.""" + soup = BeautifulSoup(html_content, 'html.parser') + token_input = soup.find('input', {'name': 'atl_token'}) + if not token_input or 'value' not in token_input.attrs: + raise ValueError("Could not find XSRF token in form") + return token_input['value'] + + def wait_for_availability(self) -> None: + """Wait for Jira to become available.""" + logger.info("Checking Jira initialization status...") + + # Initial wait for Jira to become responsive (matching job's 5s wait) + logger.info("Initial 5 second wait for Jira to become responsive...") + time.sleep(5) + + for attempt in range(self.config.MAX_RETRIES): + try: + # First try to trigger database setup + logger.info("Triggering database setup...") + setup_response = self._make_request( + "GET", + f"{self.config.base_url}/secure/SetupDatabase!default.jspa", + expected_status_codes=(200, 302, 303, 404, 500), + long_timeout=True + ) + + # Then check status + status_response = self._make_request( + "GET", + self.config.urls['status'], + expected_status_codes=(200, 302, 303, 404, 500), + long_timeout=True + ) + + try: + logger.debug(f"Full status response: {status_response.text}") + state = status_response.json().get("state", "") + logger.info(f"Current Jira state: {state}") + if state == "FIRST_RUN": + logger.info("Found FIRST_RUN state! Waiting 5s seconds for initialization...") + time.sleep(5) # Match the job's 5s wait after finding FIRST_RUN + logger.info("Proceeding with setup...") + return + if state not in ["FIRST_RUN", "RUNNING"]: + logger.info(f"Jira state is {state}, waiting...") + time.sleep(self.config.RETRY_INTERVAL) + continue + + # Check if setup wizard is accessible + response = self._make_request( + "GET", + f"{self.config.base_url}/secure/SetupApplicationProperties!default.jspa", + expected_status_codes=(200, 302, 303, 404, 500) + ) + + if "jira-setupwizard" in response.text: + logger.info("Jira is available and ready for setup!") + return + + logger.info("Jira is running but not ready for setup, waiting...") + + except ValueError: + logger.warning("Could not parse Jira state") + time.sleep(self.config.RETRY_INTERVAL) + continue + + except Exception as e: + logger.warning(f"Attempt {attempt + 1} failed: {str(e)}") + + time.sleep(self.config.RETRY_INTERVAL) + + raise TimeoutError("Jira did not become available in time") + + def setup_application_properties(self) -> None: + """Set up initial Jira application properties.""" + logger.info("Setting up application properties...") + + # Get the setup page + response = self._make_request( + "GET", + f"{self.config.base_url}/secure/SetupApplicationProperties!default.jspa", + expected_status_codes=(200, 302), + long_timeout=True + ) + soup = BeautifulSoup(response.text, 'html.parser') + + # Get the XSRF token + token = soup.find('input', {'name': 'atl_token'}) + if not token: + raise ValueError("Could not find XSRF token") + token_value = token.get('value') + + # Submit application properties + payload = { + "atl_token": token_value, + "title": "Jira", + "mode": "private", + "baseURL": self.config.base_url, # Use config base_url + "setupType": "custom" # Add setupType as seen in form + } + + logger.info("Submitting application properties...") + response = self._make_request( + "POST", + f"{self.config.base_url}/secure/SetupApplicationProperties.jspa", + data=payload, + expected_status_codes=(200, 302), + long_timeout=True + ) + + # Parse the response to check for errors + soup = BeautifulSoup(response.text, 'html.parser') + + # Check for error messages + error_msgs = soup.find_all(class_=['error', 'aui-message-error']) + if error_msgs: + for error in error_msgs: + logger.error(f"Form error: {error.get_text()}") + raise RequestException("Application properties setup failed due to form errors") + + # Try to get the next form - either license setup or app properties + next_form = soup.find('form', {'id': 'jira-setupwizard'}) or soup.find('form', {'id': 'setupLicenseForm'}) + if next_form: + logger.info("Successfully advanced to license setup") + return + + # If we're still on the app properties page, there might be validation errors + if soup.find('form', {'action': 'SetupApplicationProperties.jspa'}): + logger.error("Still on application properties page") + logger.debug(f"Response content: {response.text}") + raise RequestException("Application properties setup did not advance to next step") + + logger.info("Application properties setup completed") + + def setup_license(self) -> None: + """Set up Jira license.""" + logger.info("Setting up license...") + + # First get the license page to obtain the form token + response = self._make_request("GET", self.config.urls['license']) + soup = BeautifulSoup(response.text, 'html.parser') + + # Extract form details + form = soup.find('form', {'id': 'jira-setupwizard'}) or soup.find('form', {'id': 'setupLicenseForm'}) + if not form: + logger.error("Could not find license setup form") + logger.debug(f"Page content: {response.text}") + raise ValueError("License setup form not found") + + token = self._extract_form_token(response.text) + logger.debug(f"Found token: {token}") + + # Get the form action URL + form_action = form.get('action', 'SetupLicense!default.jspa') + if not form_action.startswith('http'): + form_action = f"{self.config.base_url}/secure/{form_action}" + + logger.debug(f"Submitting license to: {form_action}") + + # Submit license with the correct token + payload = { + "setupLicenseKey": self.config.JIRA_LICENSE_KEY, + "atl_token": token, + "next": "Next" + } + + logger.debug(f"Submitting payload: {payload}") + response = self._make_request("POST", form_action, data=payload) + + # Check for error messages in the response + soup = BeautifulSoup(response.text, 'html.parser') + error_msgs = soup.find_all(class_=['error', 'aui-message-error']) + if error_msgs: + for error in error_msgs: + logger.error(f"Form error: {error.get_text()}") + raise RequestException("License submission failed due to form errors") + + # Check if we're still on the license page + if "SetupLicense" in response.url: + logger.error("Still on license page after submission") + logger.debug(f"Response content: {response.text}") + raise RequestException("License submission did not advance to next step") + + logger.info("License setup completed") + + def setup_admin(self) -> None: + """Set up admin account and complete setup wizard.""" + logger.info("Setting up admin account (final setup step)...") + + # Get admin setup page and extract token + response = self._make_request("GET", self.config.urls['admin']) + soup = BeautifulSoup(response.text, 'html.parser') + + # Extract form details + form = soup.find('form', {'class': 'aui'}) + if not form: + logger.error("Could not find admin setup form") + logger.debug(f"Page content: {response.text}") + raise ValueError("Admin setup form not found") + + token = soup.find('input', {'name': 'atl_token'}) + if not token: + raise ValueError("Could not find XSRF token") + token_value = token.get('value') + + # Submit admin details + payload = { + "fullname": self.config.JIRA_ADMIN_FULLNAME, + "email": self.config.JIRA_ADMIN_EMAIL, + "username": self.config.JIRA_ADMIN_USERNAME, + "password": self.config.JIRA_ADMIN_PASSWORD, + "confirm": self.config.JIRA_ADMIN_PASSWORD, + "atl_token": token_value, + "next": "Next" + } + + logger.info("Submitting admin account details...") + response = self._make_request( + "POST", + self.config.urls['setup_admin'], + data=payload, + expected_status_codes=(200, 302), + long_timeout=True + ) + + # Now handle the mail notifications page + response = self._make_request("GET", self.config.urls['mail']) + soup = BeautifulSoup(response.text, 'html.parser') + token = soup.find('input', {'name': 'atl_token'}) + if not token: + raise ValueError("Could not find XSRF token for mail setup") + token_value = token.get('value') + + # Submit the final step - select Later and click Finish + payload = { + "atl_token": token_value, + "noemail": "true", + "finish": "Finish", + "baseURL": self.config.base_url # Reaffirm the base URL in final step + } + + logger.debug("Completing setup with email configuration later...") + response = self._make_request( + "POST", + self.config.urls['setup_mail'], + data=payload, + expected_status_codes=(200, 302), + long_timeout=True + ) + + logger.info("Setup completed!") + + def verify_setup(self) -> None: + """Verify Jira setup completion.""" + logger.info("Verifying setup...") + response = self._make_request("GET", self.config.urls['status']) + state = response.json().get("state", "UNKNOWN") + + if state not in ["RUNNING", "FIRST_RUN"]: + raise RuntimeError(f"Unexpected Jira state after setup: {state}") + logger.info(f"Setup verified successfully! Jira state: {state}") + + def get_jira_state(self) -> str: + """Get the current state of Jira.""" + response = self._make_request("GET", self.config.urls['status']) + return response.json().get("state", "UNKNOWN") + + def main(): + setup = JiraSetup() + max_retries = 3 + retry_count = 0 + + while retry_count < max_retries: + try: + setup.wait_for_availability() + setup.setup_application_properties() # First step - Set title, mode, base URL + setup.setup_license() # Second step - Submit license + setup.setup_admin() # Final step - Admin account and email config + setup.verify_setup() + logger.info("Setup completed successfully") + return 0 + except Exception as e: + retry_count += 1 + if retry_count >= max_retries: + logger.error(f"Setup failed: {str(e)}") + return 1 + logger.warning(f"Attempt {retry_count} failed: {str(e)}") + logger.info("Waiting 30 seconds before retrying...") + time.sleep(30) + + if __name__ == "__main__": + main() diff --git a/chart/templates/jira-setup-job.yaml b/chart/templates/jira-setup-job.yaml new file mode 100644 index 0000000..2ee67e2 --- /dev/null +++ b/chart/templates/jira-setup-job.yaml @@ -0,0 +1,109 @@ +# Copyright 2025 Defense Unicorns +# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +apiVersion: batch/v1 +kind: Job +metadata: + name: jira-setup + labels: + app.kubernetes.io/name: jira +spec: + backoffLimit: {{ .Values.setup.job.backoffLimit }} + activeDeadlineSeconds: {{ .Values.setup.job.activeDeadlineSeconds }} + ttlSecondsAfterFinished: {{ .Values.setup.job.ttlSecondsAfterFinished }} + template: + metadata: + labels: + app.kubernetes.io/name: jira + spec: + restartPolicy: OnFailure + initContainers: + - name: wait-for-jira + image: {{ .Values.setup.job.image }} + command: ['sh', '-c'] + args: + - | + sleep 10 + echo "Proceeding with setup..." + env: + - name: JIRA_DOMAIN + value: jira.{{ .Values.domain }} + containers: + - name: setup + image: {{ .Values.setup.job.image }} + imagePullPolicy: IfNotPresent + securityContext: + runAsUser: 0 + command: ["/bin/bash", "-c"] + args: + - | + set -ex + dnf install -y --allowerasing python3 python3-pip curl + + # Create and activate virtual environment + python3 -m venv /setup/venv + source /setup/venv/bin/activate + + # Install packages in the virtual environment + pip install --no-cache-dir requests beautifulsoup4 + + # Make venv accessible to main container + chmod -R 755 /setup/venv + source /setup/venv/bin/activate + python3 /scripts/test_license.py + + echo "Waiting 10 seconds before updating base URL..." + sleep 10 + echo "Updating base URL..." + curl -vvvv -k -X PUT "https://${JIRA_DOMAIN}/rest/api/2/settings/baseUrl" \ + --user "${JIRA_ADMIN_USERNAME}:${JIRA_ADMIN_PASSWORD}" \ + -H "Content-Type: application/json" \ + -d "https://${JIRA_DOMAIN}" + env: + - name: JIRA_DOMAIN + value: jira.{{ .Values.domain }} + - name: JIRA_ADMIN_USERNAME + valueFrom: + secretKeyRef: + name: jira-admin + key: username + - name: JIRA_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: jira-admin + key: password + - name: JIRA_ADMIN_EMAIL + valueFrom: + secretKeyRef: + name: jira-admin + key: email + - name: JIRA_ADMIN_FULLNAME + valueFrom: + secretKeyRef: + name: jira-admin + key: fullname + - name: JIRA_LICENSE_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.jira.license.secretName }} + key: license_key + resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: 1000m + memory: 1Gi + volumeMounts: + - name: script + mountPath: /scripts + readOnly: true + - name: setup + mountPath: /setup + volumes: + - name: script + configMap: + name: jira-setup-script + defaultMode: 0755 + - name: setup + emptyDir: {} diff --git a/chart/templates/license-secret.yaml b/chart/templates/license-secret.yaml new file mode 100644 index 0000000..af14ad4 --- /dev/null +++ b/chart/templates/license-secret.yaml @@ -0,0 +1,11 @@ +# Copyright 2025 Defense Unicorns +# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.jira.license.secretName }} + namespace: {{ .Release.Namespace }} +type: Opaque +stringData: + license_key: {{ .Values.jira.license.stringData.license_key }} \ No newline at end of file diff --git a/chart/values.yaml b/chart/values.yaml index 9c5024c..6f7497f 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -24,19 +24,30 @@ sso: defaultClientScopes: [] requiredGroups: [] -# custom: -# # Notice no `remoteGenerated` field here on custom internal rule -# - direction: Ingress -# selector: -# app: jenkins -# remoteNamespace: jenkins -# remoteSelector: -# app: jenkins -# port: 8180 -# description: "Ingress from Jenkins" -# # No `remoteNamespace`, `remoteSelector`, or `port` fields on rule to `remoteGenerated` -# - direction: Egress -# selector: -# app: webservice -# remoteGenerated: Anywhere -# description: "Egress from Mattermost" +setup: + # Admin user configuration + admin: + username: "admin" + password: "admin" + email: "admin@example.com" + fullname: "Jira Administrator" + + # Setup job configuration + job: + image: registry.access.redhat.com/ubi9:9.4-1214.1726694543 + resources: + requests: + cpu: 2000m + memory: 4Gi + limits: + cpu: 6 + memory: 6Gi + backoffLimit: 6 + activeDeadlineSeconds: 600 + ttlSecondsAfterFinished: 100 +jira: + license: + secretName: jira-license + secretKey: license_key + stringData: + license_key: "AAAB4Q0ODAoPeNqVUk2P2jAUvOdXROqlVRUUB7JZkCx1CaZkC8luAogDFxMexAWcyHZg2V/ffICgZRepR4/tmTfz5ss4B/2Zch21dIQ6VqvTtHU3GuuWadmaK4AqlvIeVYBLxDCRgWxtyGLgEsbHDHy6A+wGoxEJXe9pqP1mgjZO92TJyt+Y+GMSvoReRDQ/3y1ABKuJBCGxgc5U5C1j4nilYxmoVZNlIl3msWqUB0OmK3WgAho0VmwPWIkctChfyFiwrBKrELKn25xezhVRwU5d4ApEDW5r6QGVCR65B7fvQtZ/d7+bi9n71DNnTh60vXCSKDLbPCXhoDn+NemSjVQTexC/Haf2epmz9uHnw+scz/HZidfDQ68XEd8YWqaD2s5j+56PSFFRTrSiW1k4AbEHUVB0iecYgdd/NUK/7RsW8rvaBo7TIrXSFHowTcd8bDaRthYAPEmzDMSd3F9yESdUwr+bvP5dRZMJJs+xEh//7eMDrY860IPLOp4Lt3p0cqt/LXeg10v4Nu/olz1pI8oKlFMe/38Zblp1Peh1U+5wfNKOc+KWFog15UzWrerBqrzVJ5zFqeBSc1OuCiVS2NhikS528Y9l/SY/PWnE6a4e4GbcCr2Z807CJ7UK/lzsD+lDVU4wLAIUDh8J3BnMCPGC9z4pMZxph+utjboCFDTa3S1umpcLoVwqWBI2JELm2ZTkX02mq" diff --git a/common/zarf.yaml b/common/zarf.yaml index 1bca8f8..fb38aa3 100644 --- a/common/zarf.yaml +++ b/common/zarf.yaml @@ -27,6 +27,21 @@ components: actions: onDeploy: after: + - description: Wait for Jira StatefulSet + wait: + cluster: + kind: StatefulSet + name: jira + namespace: jira + maxTotalSeconds: 300 + - wait: + cluster: + kind: Job + name: jira-setup + condition: "'{.status.succeeded}'=1" + namespace: jira + maxTotalSeconds: 300 + description: Jira Setup job succeeded - description: Validate Jira Package maxTotalSeconds: 300 wait: diff --git a/release-please-config.json b/release-please-config.json deleted file mode 100644 index b883dd5..0000000 --- a/release-please-config.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "packages": { - ".": { - "changelog-path": "CHANGELOG.md", - "changelog-sections": [ - { "type": "feat", "section": "Features", "hidden": false }, - { "type": "fix", "section": "Bug Fixes", "hidden": false }, - { "type": "chore", "section": "Miscellaneous", "hidden": false } - ], - "release-type": "simple", - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true, - "draft": false, - "versioning": "prerelease", - "prerelease-type": "uds", - "extra-files": [ - "bundle/uds-bundle.yaml", - "tasks.yaml", - "zarf.yaml" - ] - } - }, - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" -} diff --git a/releaser.yaml b/releaser.yaml index 9f870db..d812f2c 100644 --- a/releaser.yaml +++ b/releaser.yaml @@ -8,3 +8,6 @@ flavors: - name: registry1 # renovate-uds: datasource=docker depName=registry1.dso.mil/ironbank/atlassian/jira-data-center/jira-node version: 10.3.2-uds.0 + - name: unicorn + # renovate-uds: datasource=docker depName=atlassian/jira-software + version: 10.3.2-uds.0 diff --git a/tasks/test.yaml b/tasks/test.yaml index 3c26a2d..217d9b1 100644 --- a/tasks/test.yaml +++ b/tasks/test.yaml @@ -6,6 +6,7 @@ tasks: actions: - task: health-check - task: ingress + - task: create-project - name: health-check actions: @@ -24,3 +25,14 @@ tasks: protocol: https address: jira.uds.dev code: 200 + + - name: create-project + description: Create a project in Jira + actions: + - cmd: | + docker run --rm --ipc=host --net=host --mount type=bind,source="$(pwd)",target=/tests mcr.microsoft.com/playwright:v1.49.1-jammy sh -c " \ + cd tests && \ + npm ci && \ + npx playwright test \ + " + dir: tests diff --git a/tests/.playwright/output/.last-run.json b/tests/.playwright/output/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/tests/.playwright/output/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/tests/.playwright/reports/test-results.json b/tests/.playwright/reports/test-results.json new file mode 100644 index 0000000..75ea450 --- /dev/null +++ b/tests/.playwright/reports/test-results.json @@ -0,0 +1,312 @@ +{ + "config": { + "configFile": "/tests/playwright.config.ts", + "rootDir": "/tests", + "forbidOnly": false, + "fullyParallel": true, + "globalSetup": null, + "globalTeardown": null, + "globalTimeout": 0, + "grep": {}, + "grepInvert": null, + "maxFailures": 0, + "metadata": { + "actualWorkers": 1 + }, + "preserveOutput": "always", + "reporter": [ + [ + "html", + { + "outputFolder": ".playwright/reports", + "open": "never" + } + ], + [ + "json", + { + "outputFile": ".playwright/reports/test-results.json", + "open": "never" + } + ], + [ + "list", + null + ] + ], + "reportSlowTests": { + "max": 5, + "threshold": 15000 + }, + "quiet": false, + "projects": [ + { + "outputDir": "/tests/.playwright/output", + "repeatEach": 1, + "retries": 0, + "metadata": {}, + "id": "setup", + "name": "setup", + "testDir": "/tests", + "testIgnore": [], + "testMatch": [ + "/.*\\.setup\\.ts/" + ], + "timeout": 30000 + }, + { + "outputDir": "/tests/.playwright/output", + "repeatEach": 1, + "retries": 0, + "metadata": {}, + "id": "chromium", + "name": "chromium", + "testDir": "/tests", + "testIgnore": [], + "testMatch": [ + "**/*.@(spec|test).?(c|m)[jt]s?(x)" + ], + "timeout": 30000 + }, + { + "outputDir": "/tests/.playwright/output", + "repeatEach": 1, + "retries": 0, + "metadata": {}, + "id": "firefox", + "name": "firefox", + "testDir": "/tests", + "testIgnore": [], + "testMatch": [ + "**/*.@(spec|test).?(c|m)[jt]s?(x)" + ], + "timeout": 30000 + } + ], + "shard": null, + "updateSnapshots": "missing", + "version": "1.49.1", + "workers": 1, + "webServer": null + }, + "suites": [ + { + "title": "jira.setup.ts", + "file": "jira.setup.ts", + "column": 0, + "line": 0, + "specs": [ + { + "title": "initial jira setup", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 30000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "setup", + "projectName": "setup", + "results": [ + { + "workerIndex": 0, + "status": "passed", + "duration": 14308, + "errors": [], + "stdout": [ + { + "text": "Ensuring user is logged out...\n" + }, + { + "text": "Logged out successfully\n" + }, + { + "text": "Navigating to login page...\n" + }, + { + "text": "Found input fields: [\n \u001b[32m'id=quickSearchInput, name=searchString, type=text'\u001b[39m,\n \u001b[32m'id=null, name=null, type=submit'\u001b[39m,\n \u001b[32m'id=username-field, name=username, type=null'\u001b[39m,\n \u001b[32m'id=password-field, name=password, type=password'\u001b[39m,\n \u001b[32m'id=rememberMe-uid1, name=rememberMe, type=checkbox'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m,\n \u001b[32m'id=jiraConcurrentRequests, name=jira.request.concurrent.requests, type=hidden'\u001b[39m,\n \u001b[32m'id=null, name=null, type=hidden'\u001b[39m\n]\n" + }, + { + "text": "Waiting for login form...\n" + }, + { + "text": "Trying selector: input[name=\"os_username\"]\n" + }, + { + "text": "Trying selector: #username-field\n" + }, + { + "text": "Found username field with selector: #username-field\n" + }, + { + "text": "Filled in username\n" + }, + { + "text": "Trying selector: input[name=\"os_password\"]\n" + }, + { + "text": "Trying selector: #password-field\n" + }, + { + "text": "Found password field with selector: #password-field\n" + }, + { + "text": "Filled in password\n" + }, + { + "text": "Trying selector: button#login-button\n" + }, + { + "text": "Found login button with selector: button#login-button\n" + }, + { + "text": "Clicked login button\n" + }, + { + "text": "Waiting for navigation after login...\n" + }, + { + "text": "Navigating to initial setup...\n" + }, + { + "text": "Selecting language...\n" + }, + { + "text": "Trying Continue button selector: input#next[value=\"Continue\"]\n" + }, + { + "text": "Found Continue button with selector: input#next[value=\"Continue\"]\n" + }, + { + "text": "Handling avatar setup...\n" + }, + { + "text": "Avatar setup page found: false\n" + }, + { + "text": "Trying Next/Skip button selector: button:has-text(\"Next\")\n" + }, + { + "text": "Trying Next/Skip button selector: button:has-text(\"Skip\")\n" + }, + { + "text": "Trying Next/Skip button selector: input[value=\"Next\"]\n" + }, + { + "text": "Found Next/Skip button with selector: input[value=\"Next\"]\n" + }, + { + "text": "Starting project creation...\n" + }, + { + "text": "Clicking Create new project...\n" + }, + { + "text": "Waiting for template selection...\n" + }, + { + "text": "Looking for Basic Software Development template...\n" + }, + { + "text": "Trying selector: input[name=\"project-template\"][id=\"com.pyxis.greenhopper.jira:basic-software-development-template\"]\n" + }, + { + "text": "Found template with selector: input[name=\"project-template\"][id=\"com.pyxis.greenhopper.jira:basic-software-development-template\"]\n" + }, + { + "text": "Waiting for Next button...\n" + }, + { + "text": "Clicking Next button...\n" + }, + { + "text": "Waiting for Select button...\n" + }, + { + "text": "Clicking Select button...\n" + }, + { + "text": "Waiting for project details form...\n" + }, + { + "text": "Filling in project details...\n" + }, + { + "text": "Submitting project form...\n" + }, + { + "text": "Verifying project creation...\n" + }, + { + "text": "Project creation verified successfully\n" + } + ], + "stderr": [], + "retry": 0, + "steps": [ + { + "title": "ensure logged out", + "duration": 2497 + }, + { + "title": "navigate to login", + "duration": 1446 + }, + { + "title": "fill in username", + "duration": 17 + }, + { + "title": "fill in password", + "duration": 9 + }, + { + "title": "click login button", + "duration": 69 + }, + { + "title": "wait for navigation after login", + "duration": 345 + }, + { + "title": "navigate to setup", + "duration": 1110 + }, + { + "title": "language selection", + "duration": 2041 + }, + { + "title": "avatar setup", + "duration": 4080 + }, + { + "title": "create first project", + "duration": 2566 + } + ], + "startTime": "2025-01-22T16:22:02.477Z", + "attachments": [] + } + ], + "status": "expected" + } + ], + "id": "beb5ebd58afc71e16b4a-5dd37fd8e1cd5262b90d", + "file": "jira.setup.ts", + "line": 8, + "column": 5 + } + ] + } + ], + "errors": [], + "stats": { + "startTime": "2025-01-22T16:22:02.126Z", + "duration": 15116.909, + "expected": 1, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + } +} \ No newline at end of file diff --git a/tests/jira.setup.ts b/tests/jira.setup.ts new file mode 100644 index 0000000..5728205 --- /dev/null +++ b/tests/jira.setup.ts @@ -0,0 +1,300 @@ +/** + * Copyright 2025 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { test, expect, Page } from "@playwright/test"; + +test("initial jira setup", async ({ page }: { page: Page }) => { + try { + await test.step('ensure logged out', async () => { + console.log('Ensuring user is logged out...'); + await page.goto('/logout'); + await page.waitForTimeout(1000); + console.log('Logged out successfully'); + }); + + await test.step('navigate to login', async () => { + console.log('Navigating to login page...'); + await page.goto('/login.jsp'); + await page.waitForLoadState('networkidle'); + + // Debug: Log all input fields on the page + const inputs = await page.$$('input'); + console.log('Found input fields:', await Promise.all(inputs.map(async input => { + const id = await input.getAttribute('id'); + const name = await input.getAttribute('name'); + const type = await input.getAttribute('type'); + return `id=${id}, name=${name}, type=${type}`; + }))); + }); + + await test.step('fill in username', async () => { + console.log('Waiting for login form...'); + await page.waitForSelector('#login-form'); + + // Try multiple possible selectors for username + const selectors = [ + 'input[name="os_username"]', + '#username-field', + 'input[id="username-field"]', + 'input[name="username"]' + ]; + + let usernameField = null; + for (const selector of selectors) { + console.log(`Trying selector: ${selector}`); + const field = page.locator(selector); + if (await field.count() > 0) { + console.log(`Found username field with selector: ${selector}`); + usernameField = field; + break; + } + } + + if (!usernameField) { + await page.screenshot({ path: 'username-field-not-found.png', fullPage: true }); + throw new Error('Could not find username field with any known selector'); + } + + await usernameField.fill('admin'); + console.log('Filled in username'); + }); + + await test.step('fill in password', async () => { + // Try multiple possible selectors for password + const selectors = [ + 'input[name="os_password"]', + '#password-field', + 'input[id="password-field"]', + 'input[name="password"]' + ]; + + let passwordField = null; + for (const selector of selectors) { + console.log(`Trying selector: ${selector}`); + const field = page.locator(selector); + if (await field.count() > 0) { + console.log(`Found password field with selector: ${selector}`); + passwordField = field; + break; + } + } + + if (!passwordField) { + await page.screenshot({ path: 'password-field-not-found.png', fullPage: true }); + throw new Error('Could not find password field with any known selector'); + } + + await passwordField.fill('admin'); + console.log('Filled in password'); + }); + + await test.step('click login button', async () => { + // Try multiple possible selectors for login button + const selectors = [ + 'button#login-button', + '#login-button', + 'input[name="login"]', + 'button[type="submit"]' + ]; + + let loginButton = null; + for (const selector of selectors) { + console.log(`Trying selector: ${selector}`); + const button = page.locator(selector); + if (await button.count() > 0) { + console.log(`Found login button with selector: ${selector}`); + loginButton = button; + break; + } + } + + if (!loginButton) { + await page.screenshot({ path: 'login-button-not-found.png', fullPage: true }); + throw new Error('Could not find login button with any known selector'); + } + + await loginButton.click(); + console.log('Clicked login button'); + }); + + await test.step('wait for navigation after login', async () => { + console.log('Waiting for navigation after login...'); + await page.waitForNavigation(); + }); + + await test.step('navigate to setup', async () => { + console.log('Navigating to initial setup...'); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + }); + + await test.step('language selection', async () => { + console.log('Selecting language...'); + // Try multiple selectors for the Continue button + const continueSelectors = [ + 'input#next[value="Continue"]', + 'button:has-text("Continue")', + 'button[type="submit"]', + '[data-test-id="continue-button"]' + ]; + + let continueFound = false; + for (const selector of continueSelectors) { + console.log(`Trying Continue button selector: ${selector}`); + const continueButton = page.locator(selector); + if (await continueButton.count() > 0) { + console.log(`Found Continue button with selector: ${selector}`); + await continueButton.click(); + continueFound = true; + break; + } + } + + if (!continueFound) { + console.log('No Continue button found, may have skipped language selection'); + } + + await page.waitForTimeout(2000); // Wait for transition + }); + + await test.step('avatar setup', async () => { + console.log('Handling avatar setup...'); + + // Wait for avatar setup page to load + await page.waitForTimeout(2000); + + // Try to locate the avatar setup heading or message + const setupText = await page.locator('text="Let\'s get started! You\'ll need an avatar"').count(); + console.log(`Avatar setup page found: ${setupText > 0}`); + + // Try multiple selectors for the Next/Skip button + const nextButtonSelectors = [ + 'button:has-text("Next")', + 'button:has-text("Skip")', + 'input[value="Next"]', + 'button.next-button', + 'button[type="submit"]', + '[data-testid="next-button"]', + 'button.avatar-picker-done' + ]; + + let nextButtonFound = false; + for (const selector of nextButtonSelectors) { + console.log(`Trying Next/Skip button selector: ${selector}`); + const nextButton = page.locator(selector); + if (await nextButton.count() > 0) { + console.log(`Found Next/Skip button with selector: ${selector}`); + await nextButton.click(); + nextButtonFound = true; + break; + } + } + + if (!nextButtonFound) { + console.log('Taking screenshot of avatar setup page...'); + await page.screenshot({ path: 'avatar-setup-page.png', fullPage: true }); + // Instead of failing, let's try to continue + console.log('Could not find Next button, attempting to proceed anyway'); + } + + await page.waitForTimeout(2000); // Wait for transition + }); + + await test.step('create first project', async () => { + console.log('Starting project creation...'); + const createProjectButton = page.locator('button#emptyProject.add-project-trigger'); + if (await createProjectButton.count() > 0) { + console.log('Clicking Create new project...'); + await createProjectButton.click(); + } + + // Wait for template selection with multiple possible selectors + console.log('Waiting for template selection...'); + + // Wait for the template container to be visible + await page.waitForSelector('.dialog-page-body.select-project-templates-page', { timeout: 5000 }); + + // Find and click the Basic Software Development template + const templateId = 'com\\.pyxis\\.greenhopper\\.jira\\:basic-software-development-template'; + console.log('Looking for Basic Software Development template...'); + + // Try multiple selector strategies + const templateSelectors = [ + // Target the radio input directly + 'input[name="project-template"][id="com.pyxis.greenhopper.jira:basic-software-development-template"]', + // Target the containing div + 'div.template.selected[data-item-module-complete-key="com.pyxis.greenhopper.jira:basic-software-development-template"]', + // Target by name and label text + 'input[name="project-template"][aria-label*="Basic software development"]' + ]; + + let templateFound = false; + for (const selector of templateSelectors) { + console.log(`Trying selector: ${selector}`); + const template = page.locator(selector); + if (await template.count() > 0) { + console.log(`Found template with selector: ${selector}`); + await template.click({ force: true }); // Use force: true since radio might be hidden + templateFound = true; + break; + } + } + + if (!templateFound) { + console.log('Taking screenshot of template selection failure...'); + await page.screenshot({ path: 'template-selection-failed.png', fullPage: true }); + throw new Error('Could not find Basic Software Development template'); + } + + // Wait for and click Next button + console.log('Waiting for Next button...'); + await page.waitForSelector('button[type="submit"], button:has-text("Next")', { timeout: 5000 }); + console.log('Clicking Next button...'); + await Promise.race([ + page.click('button[type="submit"]'), + page.click('button:has-text("Next")') + ]); + + // Wait for and click Select button + console.log('Waiting for Select button...'); + await page.waitForSelector('button:has-text("Select")', { timeout: 5000 }); + console.log('Clicking Select button...'); + await page.click('button:has-text("Select")'); + + // Wait for project details form + console.log('Waiting for project details form...'); + await page.waitForSelector('input#name, input[name="name"]', { timeout: 5000 }); + + // Fill in project details + console.log('Filling in project details...'); + await page.fill('input#name, input[name="name"]', 'test'); + await page.fill('input#key, input[name="key"]', 'TEST'); + + // Submit form and wait for navigation + console.log('Submitting project form...'); + await page.click('button:has-text("Submit")'); + + // Wait for navigation to project page + await page.waitForURL(/.*\/projects\/TEST\/.*$/, { timeout: 10000 }).catch(async () => { + await page.screenshot({ path: 'project-creation-failed.png', fullPage: true }); + throw new Error('Project creation failed - did not navigate to project page'); + }); + + // Verify project creation by checking for "Open issues" search + console.log('Verifying project creation...'); + const openIssuesHeading = page.locator('h1:has-text("Open issues")'); + const switchFilter = page.locator('button:has-text("Switch filter")'); + + await expect(openIssuesHeading).toBeVisible({ timeout: 5000 }); + await expect(switchFilter).toBeVisible({ timeout: 5000 }); + + console.log('Project creation verified successfully'); + }); + } catch (error) { + // Take a final screenshot on any unhandled error + await page.screenshot({ path: `error-${Date.now()}.png`, fullPage: true }); + throw error; + } +}); \ No newline at end of file diff --git a/tests/package-lock.json b/tests/package-lock.json new file mode 100644 index 0000000..253d5b8 --- /dev/null +++ b/tests/package-lock.json @@ -0,0 +1,103 @@ +{ + "name": "uds-package-bitbucket", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "uds-package-bitbucket", + "license": "Apache-2.0", + "dependencies": { + "playwright": "^1.49.1" + }, + "devDependencies": { + "@playwright/test": "^1.47.2", + "@types/node": "^20.16.7", + "typescript": "^5.6.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", + "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "dev": true, + "dependencies": { + "playwright": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.17.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.14.tgz", + "integrity": "sha512-w6qdYetNL5KRBiSClK/KWai+2IMEJuAj+EujKCumalFOwXtvOXaEan9AuwcRID2IcOIAWSIfR495hBtgKlx2zg==", + "dev": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "dependencies": { + "playwright-core": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true + } + } +} diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..65eeb93 --- /dev/null +++ b/tests/package.json @@ -0,0 +1,12 @@ +{ + "name": "uds-package-bitbucket", + "license": "Apache-2.0", + "devDependencies": { + "@playwright/test": "^1.47.2", + "@types/node": "^20.16.7", + "typescript": "^5.6.2" + }, + "dependencies": { + "playwright": "^1.49.1" + } +} diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts new file mode 100644 index 0000000..542549c --- /dev/null +++ b/tests/playwright.config.ts @@ -0,0 +1,48 @@ +/** + * Copyright 2025 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { defineConfig, devices } from '@playwright/test'; + +export const playwrightDir = '.playwright'; +export const authFile = `${playwrightDir}/auth/user.json`; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + fullyParallel: true, + forbidOnly: !!process.env.CI, // fail CI if you accidently leave `test.only` in source + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: [ + // Reporter to use. See https://playwright.dev/docs/test-reporters + ['html', { outputFolder: `${playwrightDir}/reports`, open: 'never' }], + ['json', { outputFile: `${playwrightDir}/reports/test-results.json`, open: 'never' }], + ['list'] + ], + + outputDir: `${playwrightDir}/output`, + + use: { + baseURL: process.env.BASE_URL || 'https://jira.uds.dev', // for `await page.goto('/')` etc + trace: 'on-first-retry', // collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer + }, + + projects: [ + { name: 'setup', testMatch: /.*\.setup\.ts/ }, // authentication + + ...[ + 'Desktop Chrome', + 'Desktop Firefox', + ].map((p) => ({ + name: devices[p].defaultBrowserType, + dependencies: ['setup'], + use: { + ...devices[p], + storageState: authFile, + }, + })), + ], +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..39814b2 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + "module": "commonjs" /* Specify what module code is generated. */, + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + "strict": true /* Enable all strict type-checking options. */, + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/values/common-values.yaml b/values/common-values.yaml index f7bc47c..af87e2c 100644 --- a/values/common-values.yaml +++ b/values/common-values.yaml @@ -42,13 +42,13 @@ jira: resources: container: requests: - cpu: "500m" - memory: "2Gi" + cpu: "2000m" + memory: "4Gi" limits: cpu: "6" memory: "6Gi" jvm: - maxHeap: "4g" + maxHeap: "4200m" securityContext: fsGroup: 2001 runAsNonRoot: true @@ -78,3 +78,5 @@ replicaCount: 1 podAnnotations: traffic.sidecar.istio.io/excludeOutboundPorts: 40001,40011 traffic.sidecar.istio.io/excludeInboundPorts: 40001,40011 + +additionalEnvironmentVariables: [] diff --git a/values/unicorn-values.yaml b/values/unicorn-values.yaml new file mode 100644 index 0000000..11c322e --- /dev/null +++ b/values/unicorn-values.yaml @@ -0,0 +1,10 @@ +# Copyright 2024 Defense Unicorns +# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + +image: + repository: atlassian/jira-software + tag: "10.3.2" + +monitoring: + jmxExporterImageRepo: bitnami/jmx-exporter + jmxExporterImageTag: 1.1.0 diff --git a/version.txt b/version.txt deleted file mode 100644 index ddea96a..0000000 --- a/version.txt +++ /dev/null @@ -1 +0,0 @@ -10.3.0-uds.0 diff --git a/zarf.yaml b/zarf.yaml index 6cc40e4..9ce8acc 100644 --- a/zarf.yaml +++ b/zarf.yaml @@ -23,8 +23,6 @@ components: path: common only: flavor: upstream - cluster: - architecture: amd64 charts: - name: jira valuesFiles: @@ -32,6 +30,23 @@ components: images: - atlassian/jira-software:10.3.2 - bitnami/jmx-exporter:1.1.0 + - registry.access.redhat.com/ubi9:9.4-1214.1726694543 + + - name: jira + required: true + description: "Deploy jira" + import: + path: common + only: + flavor: unicorn + charts: + - name: jira + valuesFiles: + - values/unicorn-values.yaml + images: + - atlassian/jira-software:10.3.2 + - bitnami/jmx-exporter:1.1.0 + - registry.access.redhat.com/ubi9:9.4-1214.1726694543 - name: jira required: true @@ -40,13 +55,12 @@ components: path: common only: flavor: registry1 - cluster: - architecture: amd64 charts: - name: jira valuesFiles: - values/registry1-values.yaml images: - registry1.dso.mil/ironbank/atlassian/jira-data-center/jira-node:10.3.2 + - registry.access.redhat.com/ubi9:9.4-1214.1726694543 # TODO: Pending an upstream pr to fix jar file location to not be hardcoded: # - registry1.dso.mil/ironbank/opensource/prometheus/jmx-exporter:1.0.1