diff --git a/web/crux-ui/e2e/utils/test.fixture.ts b/web/crux-ui/e2e/utils/test.fixture.ts index 6168eea51..40e79db5d 100644 --- a/web/crux-ui/e2e/utils/test.fixture.ts +++ b/web/crux-ui/e2e/utils/test.fixture.ts @@ -22,7 +22,7 @@ test.beforeEach(async ({ page }, testInfo) => { return } - console.log(`[${testInfo.title}] ${type.toUpperCase()} ${it.text()}`) + console.info(`[${testInfo.title}] ${type.toUpperCase()} ${it.text()}`) }) if (CPU_THROTTLE) { diff --git a/web/crux-ui/e2e/utils/websocket-match.ts b/web/crux-ui/e2e/utils/websocket-match.ts index 535b0a257..b3fe0e6ad 100644 --- a/web/crux-ui/e2e/utils/websocket-match.ts +++ b/web/crux-ui/e2e/utils/websocket-match.ts @@ -1,3 +1,5 @@ +import { UniqueSecretKeyValue } from '@app/models' + export const wsPatchMatchPorts = (internalPort: string, externalPort?: string) => (payload: any) => { const internal = Number.parseInt(internalPort, 10) const external = externalPort ? Number.parseInt(externalPort, 10) : null @@ -22,6 +24,24 @@ export const wsPatchMatchPortRange = ) } +export const wsPatchMatchEverySecret = + (secretKeys: string[]) => + (payload: any): boolean => { + const payloadSecretKeys: string[] = payload.config?.secrets?.map(it => it?.key) ?? [] + return secretKeys.every(it => payloadSecretKeys.includes(it)) + } + +export const wsPatchMatchNonNullSecretValues = + (secretKeys: string[]) => + (payload: any): boolean => { + const payloadSecrets: UniqueSecretKeyValue[] = payload.config?.secrets ?? [] + + return ( + secretKeys.every(secKey => payloadSecrets.find(it => it.key === secKey)) && + payloadSecrets.every(it => typeof it.value === 'string') + ) + } + export const wsPatchMatchSecret = (secret: string, required: boolean) => (payload: any) => payload.config?.secrets?.some(it => it.key === secret && it.required === required) diff --git a/web/crux-ui/e2e/with-login/deployment/deployment-copy.spec.ts b/web/crux-ui/e2e/with-login/deployment/deployment-copy.spec.ts new file mode 100644 index 000000000..361f8ec98 --- /dev/null +++ b/web/crux-ui/e2e/with-login/deployment/deployment-copy.spec.ts @@ -0,0 +1,159 @@ +import { UniqueSecretKeyValue, WS_TYPE_PATCH_IMAGE, WS_TYPE_PATCH_INSTANCE } from '@app/models' +import { Page, expect } from '@playwright/test' +import { wsPatchMatchEverySecret, wsPatchMatchNonNullSecretValues } from 'e2e/utils/websocket-match' +import { DAGENT_NODE, NGINX_TEST_IMAGE_WITH_TAG, TEAM_ROUTES, waitForURLExcept } from '../../utils/common' +import { createNode } from '../../utils/nodes' +import { + addDeploymentToVersion, + createImage, + createProject, + createVersion, + fillDeploymentPrefix, +} from '../../utils/projects' +import { test } from '../../utils/test.fixture' +import { waitSocketRef, wsPatchSent } from '../../utils/websocket' + +const addSecretToImage = async ( + page: Page, + projectId: string, + versionId: string, + imageId: string, + secretKeys: string[], +): Promise => { + const sock = waitSocketRef(page) + await page.goto(TEAM_ROUTES.project.versions(projectId).imageDetails(versionId, imageId)) + await page.waitForSelector('h2:text-is("Image")') + const ws = await sock + const wsRoute = TEAM_ROUTES.project.versions(projectId).detailsSocket(versionId) + + const jsonEditorButton = await page.waitForSelector('button:has-text("JSON")') + await jsonEditorButton.click() + + const jsonEditor = await page.locator('textarea') + const json = JSON.parse(await jsonEditor.inputValue()) + json.secrets = secretKeys.map(key => ({ key, required: false })) + + const wsSent = wsPatchSent(ws, wsRoute, WS_TYPE_PATCH_IMAGE, wsPatchMatchEverySecret(secretKeys)) + await jsonEditor.fill(JSON.stringify(json)) + await wsSent +} + +const openContainerConfigByDeploymentTable = async (page: Page, containerName: string): Promise => { + const instancesTabelBody = await page.locator('.table-row-group') + const instanceRows = await instancesTabelBody.locator('.table-row') + await expect(instanceRows).toHaveCount(1) + + await expect(page.locator(`div.table-cell:has-text("${containerName}")`).first()).toBeVisible() + const containerSettingsButton = await page.waitForSelector( + `[src="/instance_config_icon.svg"]:right-of(:text("${containerName}"))`, + ) + await containerSettingsButton.click() + + await page.waitForSelector(`h2:has-text("Container")`) +} + +test.describe('Deployment Copy', () => { + const projectName = 'depl-cpy' + const newNodeName = projectName + const originalPrefix = `dcpy` + + let originalDeploymentId: string + const secretKeys = ['secretOne', 'secretTwo'] + const newSecretKey = 'new-secret' + const newSecretKeyList = [...secretKeys, newSecretKey] + + test.beforeAll(async ({ browser }) => { + const ctx = await browser.newContext() + const page = await ctx.newPage() + + const projectId = await createProject(page, projectName, 'versioned') + await createNode(page, newNodeName) + + const versionId = await createVersion(page, projectId, '0.1.0', 'Incremental') + const imageId = await createImage(page, projectId, versionId, NGINX_TEST_IMAGE_WITH_TAG) + + await addSecretToImage(page, projectId, versionId, imageId, secretKeys) + + const { id: deploymentId } = await addDeploymentToVersion(page, projectId, versionId, DAGENT_NODE, { + prefix: originalPrefix, + }) + originalDeploymentId = deploymentId + + const sock = waitSocketRef(page) + await page.goto(TEAM_ROUTES.deployment.details(originalDeploymentId)) + await page.waitForSelector('h2:text-is("Deployments")') + const ws = await sock + + await openContainerConfigByDeploymentTable(page, 'nginx') + + const newSecretValue = 'new-secret-value' + + const newSecertKeyInput = page.locator('input[placeholder="Key"][value=""]:below(label:has-text("SECRETS"))') + await newSecertKeyInput.fill(newSecretKey) + + const newSecretValueInput = page.locator( + `input[placeholder="Value"][value=""]:near(input[placeholder="Key"][value="${newSecretKey}"], 10)`, + ) + await newSecretValueInput.fill(newSecretValue) + + const wsRoute = TEAM_ROUTES.deployment.detailsSocket(originalDeploymentId) + const wsSent = wsPatchSent(ws, wsRoute, WS_TYPE_PATCH_INSTANCE, wsPatchMatchNonNullSecretValues(newSecretKeyList)) + await page.locator(`button:has-text("Save"):below(input[value="${newSecretValue}"])`).click() + await wsSent + + await page.close() + await ctx.close() + }) + + test('should copy secret values to the same node', async ({ page }) => { + await page.goto(TEAM_ROUTES.deployment.details(originalDeploymentId)) + await page.waitForSelector('h2:text-is("Deployments")') + + const copyButton = page.locator('button:has-text("Copy")') + await copyButton.click() + + const newPrefix = 'dcpy-second' + await page.locator(`button:has-text("${DAGENT_NODE}")`).click() + await fillDeploymentPrefix(page, newPrefix) + + const currentUrl = page.url() + await page.locator('button:has-text("Copy")').click() + await waitForURLExcept(page, { startsWith: `${TEAM_ROUTES.deployment.list()}/`, except: currentUrl }) + await page.waitForSelector('h2:text-is("Deployments")') + + await expect(page.locator('.bg-dyo-turquoise:has-text("Preparing")')).toHaveCount(1) + + await openContainerConfigByDeploymentTable(page, 'nginx') + + const newSecretValueInput = page.locator( + `input[placeholder="Value"]:near(input[placeholder="Key"][value="${newSecretKey}"], 10)`, + ) + + await expect(newSecretValueInput).toBeDisabled() + await expect(newSecretValueInput).toHaveValue(/^(?!\s*$).+/) // match anything but an empty string + }) + + test('should delete secret values to a different node', async ({ page }) => { + await page.goto(TEAM_ROUTES.deployment.details(originalDeploymentId)) + await page.waitForSelector('h2:text-is("Deployments")') + + const copyButton = page.locator('button:has-text("Copy")') + await copyButton.click() + + await page.locator(`button:has-text("${newNodeName}")`).click() + await fillDeploymentPrefix(page, originalPrefix) + + const currentUrl = page.url() + await page.locator('button:has-text("Copy")').click() + await waitForURLExcept(page, { startsWith: `${TEAM_ROUTES.deployment.list()}/`, except: currentUrl }) + await page.waitForSelector('h2:text-is("Deployments")') + + await expect(page.locator('.bg-dyo-turquoise:has-text("Preparing")')).toHaveCount(1) + + await openContainerConfigByDeploymentTable(page, 'nginx') + + const newSecretKeyInput = page.locator(`input[placeholder="Key"][value="${newSecretKey}"]`) + + await expect(await newSecretKeyInput.count()).toEqual(0) + }) +}) diff --git a/web/crux-ui/e2e/with-login/deployment/deployment-copyability-versioned.spec.ts b/web/crux-ui/e2e/with-login/deployment/deployment-copyability-versioned.spec.ts index 833b1ee61..1a6be3138 100644 --- a/web/crux-ui/e2e/with-login/deployment/deployment-copyability-versioned.spec.ts +++ b/web/crux-ui/e2e/with-login/deployment/deployment-copyability-versioned.spec.ts @@ -1,6 +1,5 @@ import { ProjectType, WS_TYPE_PATCH_IMAGE } from '@app/models' -import { expect, Page } from '@playwright/test' -import { test } from '../../utils/test.fixture' +import { Page, expect } from '@playwright/test' import { NGINX_TEST_IMAGE_WITH_TAG, TEAM_ROUTES, waitForURLExcept } from '../../utils/common' import { deployWithDagent } from '../../utils/node-helper' import { createNode } from '../../utils/nodes' @@ -11,7 +10,8 @@ import { createVersion, fillDeploymentPrefix, } from '../../utils/projects' -import { waitSocketRef as waitSocketRef, wsPatchSent } from '../../utils/websocket' +import { test } from '../../utils/test.fixture' +import { waitSocketRef, wsPatchSent } from '../../utils/websocket' const setup = async ( page: Page, diff --git a/web/crux-ui/src/components/deployments/add-deployment-card.tsx b/web/crux-ui/src/components/deployments/add-deployment-card.tsx index 67130e50c..19153ff29 100644 --- a/web/crux-ui/src/components/deployments/add-deployment-card.tsx +++ b/web/crux-ui/src/components/deployments/add-deployment-card.tsx @@ -201,7 +201,7 @@ const AddDeploymentCard = (props: AddDeploymentCardProps) => { ) : !versions && formik.values.projectId ? ( {t('common:loading')} ) : versions.length === 0 ? ( - {t('noVersions')} + ) : ( currentProject.type === 'versioned' && ( <> diff --git a/web/crux-ui/src/components/projects/versions/deployments/add-deployment-card.tsx b/web/crux-ui/src/components/projects/versions/deployments/add-deployment-to-version-card.tsx similarity index 96% rename from web/crux-ui/src/components/projects/versions/deployments/add-deployment-card.tsx rename to web/crux-ui/src/components/projects/versions/deployments/add-deployment-to-version-card.tsx index 89b91825d..d4cd3539c 100644 --- a/web/crux-ui/src/components/projects/versions/deployments/add-deployment-card.tsx +++ b/web/crux-ui/src/components/projects/versions/deployments/add-deployment-to-version-card.tsx @@ -26,7 +26,7 @@ import { useEffect } from 'react' import toast from 'react-hot-toast' import useSWR from 'swr' -interface AddDeploymentCardProps { +interface AddDeploymentToVersionCardProps { className?: string projectName: string versionId: string @@ -34,7 +34,7 @@ interface AddDeploymentCardProps { onDiscard: VoidFunction } -const AddDeploymentCard = (props: AddDeploymentCardProps) => { +const AddDeploymentToVersionCard = (props: AddDeploymentToVersionCardProps) => { const { projectName, versionId, className, onAdd, onDiscard } = props const { t } = useTranslation('deployments') @@ -171,4 +171,4 @@ const AddDeploymentCard = (props: AddDeploymentCardProps) => { ) } -export default AddDeploymentCard +export default AddDeploymentToVersionCard diff --git a/web/crux-ui/src/components/projects/versions/version-sections.tsx b/web/crux-ui/src/components/projects/versions/version-sections.tsx index 080bf4ef7..f4ef1dcc5 100644 --- a/web/crux-ui/src/components/projects/versions/version-sections.tsx +++ b/web/crux-ui/src/components/projects/versions/version-sections.tsx @@ -4,7 +4,7 @@ import { ProjectDetails, VERSION_SECTIONS_STATE_VALUES } from '@app/models' import { parseStringUnionType } from '@app/utils' import { useRouter } from 'next/dist/client/router' import React, { useEffect, useRef } from 'react' -import AddDeploymentCard from './deployments/add-deployment-card' +import AddDeploymentToVersionCard from './deployments/add-deployment-to-version-card' import CopyDeploymentCard from './deployments/copy-deployment-card' import AddImagesCard from './images/add-images-card' import { VerionState, VersionActions, VersionSection } from './use-version-state' @@ -59,7 +59,7 @@ const VersionSections = (props: VersionSectionsProps) => { ) : state.addSection === 'image' ? ( ) : state.addSection === 'deployment' ? ( - this.prisma.instance.create({ @@ -962,7 +964,7 @@ export default class DeployService { commands: toPrismaJson(it.config.commands), args: toPrismaJson(it.config.args), environment: toPrismaJson(it.config.environment), - secrets: toPrismaJson(it.config.secrets), + secrets: differentNode ? null : toPrismaJson(it.config.secrets), initContainers: toPrismaJson(it.config.initContainers), logConfig: toPrismaJson(it.config.logConfig), restartPolicy: it.config.restartPolicy,