init
This commit is contained in:
parent
4d465aa251
commit
17e434dd92
90
tasks/git-replace/task.yaml
Normal file
90
tasks/git-replace/task.yaml
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
apiVersion: tekton.dev/v1
|
||||||
|
kind: Task
|
||||||
|
metadata:
|
||||||
|
name: git-replace-multi
|
||||||
|
annotations:
|
||||||
|
tekton.dev/pipelines.minVersion: "0.19.0"
|
||||||
|
tekton.dev/categories: GitOps
|
||||||
|
tekton.dev/tags: git, devops
|
||||||
|
tekton.dev/displayName: "replace multiple files/dirs in git repository"
|
||||||
|
tekton.dev/platforms: "linux/amd64"
|
||||||
|
spec:
|
||||||
|
description: |
|
||||||
|
Replaces multiple files or directories in a Git repository, committing and pushing the changes.
|
||||||
|
|
||||||
|
params:
|
||||||
|
- name: repositoryUrl
|
||||||
|
type: string
|
||||||
|
description: Source repository URL
|
||||||
|
|
||||||
|
- name: branch
|
||||||
|
type: string
|
||||||
|
default: main
|
||||||
|
description: Git branch to push to
|
||||||
|
|
||||||
|
- name: targetPaths
|
||||||
|
type: array
|
||||||
|
description: List of target paths in the repo (file or directory, e.g. dir/ or file)
|
||||||
|
|
||||||
|
- name: sourcePaths
|
||||||
|
type: array
|
||||||
|
description: List of source paths in the workspace (file or directory, e.g. dir/ or file)
|
||||||
|
|
||||||
|
- name: commitMessage
|
||||||
|
type: string
|
||||||
|
default: "chore(gitops): replace multiple files or dirs"
|
||||||
|
description: Commit message
|
||||||
|
|
||||||
|
workspaces:
|
||||||
|
- name: base
|
||||||
|
- name: tmp
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: clone-repository
|
||||||
|
image: alpine/git:latest
|
||||||
|
script: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
REPO_URL="$(params.repositoryUrl)"
|
||||||
|
BRANCH="$(params.branch)"
|
||||||
|
WORKSPACE_PATH="$(workspaces.tmp.path)"
|
||||||
|
git clone --branch "${BRANCH}" "${REPO_URL}" "${WORKSPACE_PATH}/repo"
|
||||||
|
cd "${WORKSPACE_PATH}/repo"
|
||||||
|
|
||||||
|
- name: sync-files
|
||||||
|
image: alpine:3.18
|
||||||
|
script: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
TARGET_PATHS=($(params.targetPaths[*]))
|
||||||
|
SOURCE_PATHS=($(params.sourcePaths[*]))
|
||||||
|
BASE_PATH="$(workspaces.base.path)/source"
|
||||||
|
REPO_PATH="$(workspaces.tmp.path)/repo"
|
||||||
|
COUNT=${#TARGET_PATHS[@]}
|
||||||
|
if [ $COUNT -ne ${#SOURCE_PATHS[@]} ]; then
|
||||||
|
echo "targetPaths와 sourcePaths의 길이가 다릅니다."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for i in $(seq 0 $(($COUNT - 1))); do
|
||||||
|
TARGET="${REPO_PATH}/${TARGET_PATHS[$i]}"
|
||||||
|
SOURCE="${BASE_PATH}/${SOURCE_PATHS[$i]}"
|
||||||
|
[ -e "${TARGET}" ] && rm -rf "${TARGET}" || true
|
||||||
|
mkdir -p $(dirname "${TARGET}")
|
||||||
|
cp -r "${SOURCE}" "${TARGET}"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: commit-and-push
|
||||||
|
image: alpine/git:latest
|
||||||
|
script: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
cd "$(workspaces.tmp.path)/repo"
|
||||||
|
git config --global user.name "tekton-bot"
|
||||||
|
git config --global user.email "tekton@example.com"
|
||||||
|
git add .
|
||||||
|
git commit -m "$(params.commitMessage)" || exit 0
|
||||||
|
git push origin HEAD:"$(params.branch)"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
77
tasks/openapi-generate/task.yaml
Normal file
77
tasks/openapi-generate/task.yaml
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
apiVersion: tekton.dev/v1
|
||||||
|
kind: Task
|
||||||
|
metadata:
|
||||||
|
name: openapi-generate
|
||||||
|
spec:
|
||||||
|
params:
|
||||||
|
- name: context
|
||||||
|
type: string
|
||||||
|
description: context directory
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
- name: packageNamePrefix
|
||||||
|
description: Rust crate name prefix
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
- name: specDomain
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
- name: specVersion
|
||||||
|
description: Rust crate version
|
||||||
|
type: string
|
||||||
|
default: "0.0.0"
|
||||||
|
- name: generator
|
||||||
|
description: specify the generator
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
# 배열 타입으로 변경
|
||||||
|
- name: generatorOptions
|
||||||
|
type: array
|
||||||
|
description: |
|
||||||
|
openapi-generator-cli options
|
||||||
|
ex) ['--additional-properties=key=value', '--enable-post-process-file']
|
||||||
|
default: []
|
||||||
|
|
||||||
|
workspaces:
|
||||||
|
- name: base
|
||||||
|
description: Git-cloned source code
|
||||||
|
|
||||||
|
results:
|
||||||
|
- name: output
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# split-arguments 단계 제거
|
||||||
|
|
||||||
|
- name: generate-code
|
||||||
|
image: openapitools/openapi-generator-cli:v7.4.0
|
||||||
|
workingDir: /workspace/base/$(params.context)/source
|
||||||
|
env:
|
||||||
|
- name: HOME
|
||||||
|
value: /workspace/base/$(params.context)/home
|
||||||
|
script: |
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
OPENAPI_FILE="specs/$(params.specDomain)/openapi.yaml"
|
||||||
|
PACKAGE_NAME="$(params.packageNamePrefix)$(params.context)"
|
||||||
|
OUTPUT="/workspace/base/$(params.context)/output/${PACKAGE_NAME}-$(date +%s)-$(head /dev/urandom | tr -dc a-z0-9 | head -c 6)"
|
||||||
|
|
||||||
|
if [ ! -d "$OUTPUT" ]; then
|
||||||
|
mkdir -p "$OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 배열 파라미터 직접 참조
|
||||||
|
GENERATOR_OPTIONS=("${params.generatorOptions[@]}")
|
||||||
|
|
||||||
|
openapi-generator-cli generate \
|
||||||
|
-i ${OPENAPI_FILE} \
|
||||||
|
-g $(params.generator) \
|
||||||
|
-o $OUTPUT \
|
||||||
|
--additional-properties=packageName="${PACKAGE_NAME}" \
|
||||||
|
--additional-properties=packageVersion=$(params.specVersion) \
|
||||||
|
--additional-properties=publish=true \
|
||||||
|
--additional-properties=disableValidator=false \
|
||||||
|
"${GENERATOR_OPTIONS[@]}" # 배열 확장
|
||||||
|
|
||||||
|
echo -n "${OUTPUT}" > $(results.output.path)
|
45
tasks/openapi-version/task.yaml
Normal file
45
tasks/openapi-version/task.yaml
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
apiVersion: tekton.dev/v1
|
||||||
|
kind: Task
|
||||||
|
metadata:
|
||||||
|
name: nx-nodejs-version
|
||||||
|
spec:
|
||||||
|
params:
|
||||||
|
- name: context
|
||||||
|
type: string
|
||||||
|
description: context directory
|
||||||
|
default: ""
|
||||||
|
- name: specDomain
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
- name: specVersion
|
||||||
|
description: Rust crate version
|
||||||
|
type: string
|
||||||
|
default: "0.0.0"
|
||||||
|
workspaces:
|
||||||
|
- name: base
|
||||||
|
description: Git-cloned source code
|
||||||
|
results:
|
||||||
|
- name: version
|
||||||
|
description: Extracted project version (e.g. 0.2.0)
|
||||||
|
steps:
|
||||||
|
- name: verify-version
|
||||||
|
image: mikefarah/yq:4.24.2
|
||||||
|
workingDir: /workspace/base/$(params.context)/source
|
||||||
|
env:
|
||||||
|
- name: HOME
|
||||||
|
value: /workspace/base/$(params.context)/home
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
OPENAPI_FILE="specs/$(params.specDomain)/openapi.yaml"
|
||||||
|
EXPECTED_VERSION="$(params.specVersion)"
|
||||||
|
ACTUAL_VERSION=$(yq '.info.version' "$OPENAPI_FILE")
|
||||||
|
|
||||||
|
echo "Expected: $EXPECTED_VERSION"
|
||||||
|
echo "Actual: $ACTUAL_VERSION"
|
||||||
|
|
||||||
|
if [ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||||
|
echo "❌ Version mismatch! Expected: $EXPECTED_VERSION, Actual: $ACTUAL_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Tag and version match: $ACTUAL_VERSION"
|
||||||
|
echo -n "$ACTUAL_VERSION" > /tekton/results/version
|
119
tasks/rust-nx-merge/task.yaml
Normal file
119
tasks/rust-nx-merge/task.yaml
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
apiVersion: tekton.dev/v1
|
||||||
|
kind: Task
|
||||||
|
metadata:
|
||||||
|
name: rust-nx-merge
|
||||||
|
annotations:
|
||||||
|
tekton.dev/pipelines.minVersion: "0.19.0"
|
||||||
|
tekton.dev/categories: GitOps
|
||||||
|
tekton.dev/tags: git, devops, nx, rust
|
||||||
|
tekton.dev/displayName: "Merge Rust projects with nx import"
|
||||||
|
tekton.dev/platforms: "linux/amd64"
|
||||||
|
spec:
|
||||||
|
description: |
|
||||||
|
Clones a git repository, merges Rust projects using nx import,
|
||||||
|
updates target project versions to match source versions.
|
||||||
|
|
||||||
|
params:
|
||||||
|
- name: context
|
||||||
|
type: string
|
||||||
|
description: context directory
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
- name: repositoryUrl
|
||||||
|
type: string
|
||||||
|
description: Target repository URL
|
||||||
|
|
||||||
|
- name: branch
|
||||||
|
type: string
|
||||||
|
default: "main"
|
||||||
|
description: Git branch to operate on
|
||||||
|
|
||||||
|
- name: workspaceName
|
||||||
|
type: string
|
||||||
|
description: Nx workspace project name to lint and test
|
||||||
|
|
||||||
|
- name: targetProjects
|
||||||
|
type: array
|
||||||
|
description: List of target project paths in the repo (e.g., ["libs/project1"])
|
||||||
|
|
||||||
|
- name: sourceProjects
|
||||||
|
type: array
|
||||||
|
description: List of source project paths in workspace (e.g., ["generated/project1"])
|
||||||
|
|
||||||
|
- name: commitMessage
|
||||||
|
type: string
|
||||||
|
default: "chore: merge projects and sync versions"
|
||||||
|
description: Commit message
|
||||||
|
|
||||||
|
workspaces:
|
||||||
|
- name: base
|
||||||
|
- name: tmp
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: clone-repository
|
||||||
|
image: alpine/git:latest
|
||||||
|
workingDir: /workspace/tmp
|
||||||
|
env:
|
||||||
|
- name: HOME
|
||||||
|
value: /workspace/base/$(params.context)/home
|
||||||
|
script: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
git clone -b $(params.branch) $(params.repositoryUrl) repo
|
||||||
|
cd repo
|
||||||
|
|
||||||
|
- name: merge-projects
|
||||||
|
image: node:18
|
||||||
|
workingDir: /workspace/tmp
|
||||||
|
script: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Install nx if not present
|
||||||
|
command -v nx >/dev/null || npm install -g nx
|
||||||
|
|
||||||
|
# Get project pairs
|
||||||
|
TARGETS=($(params.targetProjects[*]))
|
||||||
|
SOURCES=($(params.sourceProjects[*]))
|
||||||
|
|
||||||
|
if [ ${#TARGETS[@]} -ne ${#SOURCES[@]} ]; then
|
||||||
|
echo "Error: targetProjects and sourceProjects length mismatch"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Process each project pair
|
||||||
|
cd repo/$(params.workspaceName)
|
||||||
|
|
||||||
|
for i in $(seq 0 $((${#TARGETS[@]}-1))); do
|
||||||
|
SOURCE_PATH="${SOURCES[$i]}"
|
||||||
|
TARGET_PROJECT="${TARGETS[$i]}"
|
||||||
|
TARGET_PATH="repo/$(params.workspaceName)/${TARGET_PROJECT}"
|
||||||
|
|
||||||
|
# Import project
|
||||||
|
echo "Importing ${SOURCE_PATH} to ${TARGET_PROJECT}"
|
||||||
|
npx nx import "${SOURCE_PATH}" "${TARGET_PROJECT}"
|
||||||
|
|
||||||
|
# Version sync
|
||||||
|
SRC_VERSION=$(grep '^version =' "${SOURCE_PATH}/Cargo.toml" | cut -d'"' -f2)
|
||||||
|
sed -i.bak "s/^version = .*/version = \"${SRC_VERSION}\"/" "${TARGET_PATH}/Cargo.toml"
|
||||||
|
rm -f "${TARGET_PATH}/Cargo.toml.bak"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: commit-and-push
|
||||||
|
image: alpine/git:latest
|
||||||
|
workingDir: /workspace/tmp
|
||||||
|
env:
|
||||||
|
- name: HOME
|
||||||
|
value: /workspace/base/$(params.context)/home
|
||||||
|
script: |
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
cd repo"
|
||||||
|
|
||||||
|
git add .
|
||||||
|
git commit -m "$(params.commitMessage)" || exit 0
|
||||||
|
git push origin HEAD:"$(params.branch)"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
@ -7,38 +7,145 @@ spec:
|
|||||||
- name: context
|
- name: context
|
||||||
type: string
|
type: string
|
||||||
default: ""
|
default: ""
|
||||||
description: context directory
|
description: "소스코드가 있는 하위 디렉토리 (없을 경우 '')"
|
||||||
|
|
||||||
- name: sonarHostUrl
|
- name: sonarqubeUrl
|
||||||
type: string
|
type: string
|
||||||
default: "https://sonarqube.unbox-x.net"
|
default: "https://sonarqube.unbox-x.net"
|
||||||
description: SonarQube server URL
|
description: SonarQube 서버 URL
|
||||||
|
|
||||||
- name: projectKey
|
- name: projectKey
|
||||||
type: string
|
type: string
|
||||||
description: SonarQube project key
|
description: SonarQube 프로젝트 키
|
||||||
|
|
||||||
|
- name: architecture
|
||||||
|
type: string
|
||||||
|
description: 프로젝트 언어: python | nodejs | typescript | rust
|
||||||
|
|
||||||
|
- name: coverageEnabled
|
||||||
|
type: string
|
||||||
|
default: "true"
|
||||||
|
description: "커버리지 수집 여부 (true | false)"
|
||||||
|
|
||||||
|
- name: qualityGateEnabled
|
||||||
|
type: string
|
||||||
|
default: "false"
|
||||||
|
description: "Quality Gate 후속 처리 활성화 여부 (예: Slack 알림 등)"
|
||||||
|
|
||||||
workspaces:
|
workspaces:
|
||||||
- name: base
|
- name: base
|
||||||
description: Workspace with shared code (e.g. from git-clone)
|
description: 소스코드가 위치한 Workspace (보통 git-clone 결과)
|
||||||
|
|
||||||
- name: sonar-auth
|
- name: sonarqube-credentials
|
||||||
description: |
|
description: SonarQube 인증용 토큰이 포함된 Workspace (파일명: token)
|
||||||
Workspace containing authentication token (file: `token`)
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: sonar-scan
|
- name: prepare-and-analyze
|
||||||
image: sonarsource/sonar-scanner-cli:5
|
image: ubuntu:22.04
|
||||||
workingDir: /workspace/base/$(params.context)/source
|
workingDir: /workspace/base/$(params.context)
|
||||||
|
env:
|
||||||
|
- name: DEBIAN_FRONTEND
|
||||||
|
value: noninteractive
|
||||||
script: |
|
script: |
|
||||||
#!/bin/sh
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SONAR_TOKEN=$(cat /workspace/sonar-auth/token)
|
PROJECT_KEY=$(params.projectKey)
|
||||||
|
ARCHITECTURE=$(params.architecture)
|
||||||
|
SONARQUBE_URL=$(params.sonarqubeUrl)
|
||||||
|
SONAR_TOKEN=$(cat /workspace/sonarqube-credentials/token)
|
||||||
|
COVERAGE_ENABLED=$(params.coverageEnabled)
|
||||||
|
QUALITY_GATE_ENABLED=$(params.qualityGateEnabled)
|
||||||
|
|
||||||
echo "📡 Running SonarQube analysis on project $(params.projectKey)..."
|
echo "📦 Preparing for architecture: $ARCHITECTURE"
|
||||||
|
echo "🛡️ Coverage enabled? $COVERAGE_ENABLED"
|
||||||
|
echo "🎯 Quality Gate enabled? $QUALITY_GATE_ENABLED"
|
||||||
|
|
||||||
|
COVERAGE_OPTION=""
|
||||||
|
|
||||||
|
case "$ARCHITECTURE" in
|
||||||
|
python)
|
||||||
|
apt update && apt install -y python3-pip curl unzip python3-venv
|
||||||
|
pip install --upgrade pip
|
||||||
|
|
||||||
|
# 설치 방식 결정: pyproject.toml + poetry.lock → poetry / requirements.txt → pip
|
||||||
|
if [ -f "pyproject.toml" ] && [ -f "poetry.lock" ]; then
|
||||||
|
# Poetry 설치 (선택적)
|
||||||
|
pip install poetry --root-user-action=ignore
|
||||||
|
|
||||||
|
echo "📦 Using Poetry for dependency management"
|
||||||
|
poetry lock
|
||||||
|
poetry install --with dev
|
||||||
|
|
||||||
|
if [ "$COVERAGE_ENABLED" = "true" ]; then
|
||||||
|
echo "🧪 Running pytest with coverage (Poetry)"
|
||||||
|
poetry run pytest --cov=. --cov-report=xml
|
||||||
|
COVERAGE_OPTION="-Dsonar.python.coverage.reportPaths=coverage.xml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
elif [ -f "requirements.txt" ]; then
|
||||||
|
echo "📦 Using pip + venv for dependency management"
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt --root-user-action=ignore
|
||||||
|
pip install pytest pytest-cov
|
||||||
|
|
||||||
|
if [ "$COVERAGE_ENABLED" = "true" ]; then
|
||||||
|
echo "🧪 Running pytest with coverage (pip)"
|
||||||
|
pytest --cov=. --cov-report=xml
|
||||||
|
COVERAGE_OPTION="-Dsonar.python.coverage.reportPaths=coverage.xml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
echo "❌ Python project must contain either pyproject.toml+poetry.lock or requirements.txt"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
nodejs|typescript)
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||||
|
apt install -y nodejs curl unzip
|
||||||
|
npm install
|
||||||
|
if [ "$COVERAGE_ENABLED" = "true" ]; then
|
||||||
|
echo "🧪 Running npm test with coverage"
|
||||||
|
npm run test -- --coverage
|
||||||
|
COVERAGE_OPTION="-Dsonar.javascript.lcov.reportPaths=coverage/lcov.info"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
rust)
|
||||||
|
apt update && apt install -y curl unzip pkg-config libssl-dev
|
||||||
|
curl https://sh.rustup.rs -sSf | bash -s -- -y
|
||||||
|
source $HOME/.cargo/env
|
||||||
|
cargo install cargo-tarpaulin
|
||||||
|
if [ "$COVERAGE_ENABLED" = "true" ]; then
|
||||||
|
echo "🧪 Running cargo tarpaulin"
|
||||||
|
cargo tarpaulin --out Xml
|
||||||
|
# Rust는 coverage 연동이 공식적으로 어려워 생략
|
||||||
|
COVERAGE_OPTION=""
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ 지원하지 않는 아키텍처입니다: $ARCHITECTURE"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "⬇️ Installing SonarScanner"
|
||||||
|
curl -sSLo sonar-scanner.zip https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-7.1.0.4889-linux-x64.zip
|
||||||
|
unzip sonar-scanner.zip
|
||||||
|
export PATH="$PWD/sonar-scanner-cli-7.1.0.4889-linux-x64/bin:$PATH"
|
||||||
|
|
||||||
|
echo "📡 Running SonarQube analysis on project: $PROJECT_KEY"
|
||||||
|
|
||||||
sonar-scanner \
|
sonar-scanner \
|
||||||
-Dsonar.projectKey=$(params.projectKey) \
|
-Dsonar.projectKey=$PROJECT_KEY \
|
||||||
-Dsonar.host.url=$(params.sonarHostUrl) \
|
-Dsonar.projectName=$PROJECT_KEY \
|
||||||
-Dsonar.login=$SONAR_TOKEN
|
-Dsonar.sources=. \
|
||||||
|
-Dsonar.host.url=$SONARQUBE_URL \
|
||||||
|
-Dsonar.login=$SONAR_TOKEN \
|
||||||
|
$COVERAGE_OPTION
|
||||||
|
|
||||||
|
if [ "$QUALITY_GATE_ENABLED" = "true" ]; then
|
||||||
|
echo "🔍 Quality Gate 후속 처리를 위한 Hook 실행 가능 (Slack, Webhook 등)"
|
||||||
|
# 여기에 Slack 연동, ArgoCD 알림, 등 후속 로직 연동 가능
|
||||||
|
fi
|
||||||
|
Loading…
x
Reference in New Issue
Block a user