Compare commits
364 commits
65d85fb890
...
59fe174633
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59fe174633 | ||
|
|
2d65973eeb | ||
|
|
fbc92e6fcc | ||
|
|
c8bacd9118 | ||
|
|
f9649fd8f4 | ||
|
|
482d7b7f27 | ||
|
|
a4b6215053 | ||
|
|
356c3513f0 | ||
|
|
7469a9a7d9 | ||
|
|
2ec70998d8 | ||
|
|
afd1e1ae11 | ||
|
|
5946bfeaeb | ||
|
|
15363d6846 | ||
|
|
89e091e5ab | ||
|
|
863b76e000 | ||
|
|
81fcfb00d3 | ||
|
|
f1dc6d59fa | ||
|
|
5c1c131a8e | ||
|
|
4c124e9cd1 | ||
|
|
710df4e3ff | ||
|
|
61e1cbbfb5 | ||
|
|
9359891d16 | ||
|
|
252d3b3b51 | ||
|
|
3c3c7cc89a | ||
|
|
077bfcf851 | ||
|
|
b912775899 | ||
|
|
ecf52d8130 | ||
|
|
d2076fb322 | ||
|
|
b2492d51c8 | ||
|
|
917298e784 | ||
|
|
e7dff30b7c | ||
|
|
981b670eb4 | ||
|
|
2833c68af9 | ||
|
|
33f3a0b100 | ||
|
|
bc165f1976 | ||
|
|
e39a9ad827 | ||
|
|
08735e6cc8 | ||
|
|
3eb5463e3e | ||
|
|
f8960f44fa | ||
|
|
217541a987 | ||
|
|
acc929a7b0 | ||
|
|
1802522c60 | ||
|
|
c329749c74 | ||
|
|
09d8ba0d89 | ||
|
|
908b5d1907 | ||
|
|
36d44faaf9 | ||
|
|
ffe37fc892 | ||
|
|
d7524a743d | ||
|
|
d300ddf902 | ||
|
|
46e70597e6 | ||
|
|
b3f3ddf0ce | ||
|
|
b26b3aabf1 | ||
|
|
9dabaabdf1 | ||
|
|
cb6a2de529 | ||
|
|
74130cbc61 | ||
|
|
8b8ec9c4f9 | ||
|
|
ec7a8b5ea9 | ||
|
|
e9f62f1fa4 | ||
|
|
c0c918f1c5 | ||
|
|
e34f4ac4fa | ||
|
|
787de4059e | ||
|
|
24fd9703cc | ||
|
|
e31251f5ae | ||
|
|
419a551d76 | ||
|
|
c6980ebe31 | ||
|
|
da70e34472 | ||
|
|
1ca472d549 | ||
|
|
88b04bb920 | ||
|
|
41fd6f5608 | ||
|
|
d525c12457 | ||
|
|
ad4a21d15f | ||
|
|
7405bd7088 | ||
|
|
2bdbb7e534 | ||
|
|
9aa40bb5f0 | ||
|
|
bc2afdf02e | ||
|
|
d742b4a678 | ||
|
|
1a7fa40e58 | ||
|
|
13e94a499d | ||
|
|
128464e77c | ||
|
|
8e81413e99 | ||
|
|
c0c8fd7f11 | ||
|
|
d892d0ad10 | ||
|
|
b38d47eac2 | ||
|
|
1fc224cd75 | ||
|
|
aaa540421f | ||
|
|
2d2634f87b | ||
|
|
d581abb284 | ||
|
|
bb1a82df5d | ||
|
|
07f381b88c | ||
|
|
d5a64d93bc | ||
|
|
be6ed4377d | ||
|
|
15a79d3847 | ||
|
|
257cabaecb | ||
|
|
63be45b21b | ||
|
|
dfe3a4086e | ||
|
|
cba12ce3e1 | ||
|
|
a047971bc1 | ||
|
|
858b1906ec | ||
|
|
903f7a7177 | ||
|
|
6c3ea7f1db | ||
|
|
50c07eb8e4 | ||
|
|
334f40af38 | ||
|
|
a3c80c9778 | ||
|
|
5acf4d9af2 | ||
|
|
6692ff3e36 | ||
|
|
d681e656c7 | ||
|
|
67687efc65 | ||
|
|
a75dc07567 | ||
|
|
d87ab279fb | ||
|
|
5eec212ade | ||
|
|
40294bc3b3 | ||
|
|
6190219c66 | ||
|
|
cd06e10b1b | ||
|
|
40d570f195 | ||
|
|
2536d612d7 | ||
|
|
93aa8894a8 | ||
|
|
10d142145b | ||
|
|
88622ba7ea | ||
|
|
51a3017e06 | ||
|
|
47682a3085 | ||
|
|
1bad82cf43 | ||
|
|
5c77fe2456 | ||
|
|
fb4c3aa22e | ||
|
|
098b098509 | ||
|
|
507ecfdf54 | ||
|
|
6dae53a0e5 | ||
|
|
aa63eaafcb | ||
|
|
60ba03427a | ||
|
|
d72c1e2a47 | ||
|
|
c7c8107dcd | ||
|
|
62caa30056 | ||
|
|
2378997c86 | ||
|
|
1ce36926f6 | ||
|
|
5ff31f2b6c | ||
|
|
7193a5d0f6 | ||
|
|
51894d9c05 | ||
|
|
a511bd9b67 | ||
|
|
5e4873322e | ||
|
|
419593e7d4 | ||
|
|
2bb07bbe9e | ||
|
|
27b33b7b36 | ||
|
|
4d0be415c8 | ||
|
|
4b2dfa7c77 | ||
|
|
0e85f59940 | ||
|
|
92d6b577fd | ||
|
|
312019cd2a | ||
|
|
38b9c1b062 | ||
|
|
538e7467e2 | ||
|
|
e30147c6f7 | ||
|
|
e7483f591f | ||
|
|
bc8928fdc4 | ||
|
|
906717a936 | ||
|
|
a5313a78cf | ||
|
|
c2e9c51942 | ||
|
|
e37f9401bc | ||
|
|
8e30035031 | ||
|
|
24fc8db8db | ||
|
|
a8db2864f3 | ||
|
|
7d9cf6f815 | ||
|
|
ec367549b4 | ||
|
|
d4ff7ea791 | ||
|
|
3db41a83a8 | ||
|
|
4f6399aaaf | ||
|
|
53f24df0a0 | ||
|
|
4d3182c938 | ||
|
|
d1d6f08392 | ||
|
|
51062e73a6 | ||
|
|
094e64ed2d | ||
|
|
4f053bf5b4 | ||
|
|
5b0be9798e | ||
|
|
164ff40f04 | ||
|
|
b5416859e5 | ||
|
|
b46d8e2601 | ||
|
|
8a52307255 | ||
|
|
d423223d38 | ||
|
|
35e95d1229 | ||
|
|
1b68f11ec0 | ||
|
|
34f15c106c | ||
|
|
c9fcc0bc44 | ||
|
|
9503d42ca6 | ||
|
|
84eb9d31bb | ||
|
|
38e855b20e | ||
|
|
ab4b2045d4 | ||
|
|
db83289a4a | ||
|
|
3ef05dd5ec | ||
|
|
8dd5d01f69 | ||
|
|
6ed03a8c0a | ||
|
|
7c28d339c8 | ||
|
|
246219220d | ||
|
|
50e198fd51 | ||
|
|
d9fe81500d | ||
|
|
ea80649b6b | ||
|
|
b81bfc7f32 | ||
|
|
d4a538a265 | ||
|
|
c5fbf78bd8 | ||
|
|
5abc01882e | ||
|
|
6cd05fb559 | ||
|
|
1af5cc11c9 | ||
|
|
bbbd46ca26 | ||
|
|
d7d9004aae | ||
|
|
37a99e5f94 | ||
|
|
eb7da5c082 | ||
|
|
902a60239d | ||
|
|
322bf7c1b2 | ||
|
|
ddd84e5adc | ||
|
|
4401bee941 | ||
|
|
dd8a0d501d | ||
|
|
c35d28f99b | ||
|
|
ef5cf3c626 | ||
|
|
20f9bb4305 | ||
|
|
90aa4e7e73 | ||
|
|
626cfb4a22 | ||
|
|
1c5d5676ef | ||
|
|
885f6eb124 | ||
|
|
98b36c1ff7 | ||
|
|
5a27427cad | ||
|
|
9d1ede0733 | ||
|
|
a07cee7ad6 | ||
|
|
499a8666db | ||
|
|
9b996b4dc9 | ||
|
|
d443758b03 | ||
|
|
9f29df4cc3 | ||
|
|
081bd6ef8c | ||
|
|
9c62996e40 | ||
|
|
6da907f1c8 | ||
|
|
0c6a233b27 | ||
|
|
7f35a2d98e | ||
|
|
dcaaf2ed68 | ||
|
|
b751628545 | ||
|
|
8e033c5bc3 | ||
|
|
f44ace3965 | ||
|
|
579ce3064f | ||
|
|
097efd14ce | ||
|
|
43884823c6 | ||
|
|
b5be9fe9e8 | ||
|
|
7677fac2f5 | ||
|
|
ed3780b2a7 | ||
|
|
ebf23f4874 | ||
|
|
a22a0c027d | ||
|
|
36bf068814 | ||
|
|
8a40c4e348 | ||
|
|
07337d9df6 | ||
|
|
21db1fd374 | ||
|
|
c5794be361 | ||
|
|
3d764d9f18 | ||
|
|
6a4964268f | ||
|
|
77779f3647 | ||
|
|
166f7e96e2 | ||
|
|
3b2c2c6e18 | ||
|
|
8e6e3551f3 | ||
|
|
4ccec6b210 | ||
|
|
27b76a4e97 | ||
|
|
9c5c39334a | ||
|
|
33e172ac24 | ||
|
|
a4d75ff0e2 | ||
|
|
a49bc5dd14 | ||
|
|
ffe8cfd76f | ||
|
|
a058786509 | ||
|
|
a13fb3cf00 | ||
|
|
5c67766f41 | ||
|
|
54a020cf1b | ||
|
|
bd744eb8dd | ||
|
|
57d879263d | ||
|
|
fc89e357fe | ||
|
|
b35524bdf8 | ||
|
|
182df1cb07 | ||
|
|
5a7408a919 | ||
|
|
fcd63f40eb | ||
|
|
e2199f1223 | ||
|
|
11dbcfcfe8 | ||
|
|
809d9b21fa | ||
|
|
941dbfd988 | ||
|
|
c1518ba1c0 | ||
|
|
283795ba94 | ||
|
|
29b372d17a | ||
|
|
00ce43a9d0 | ||
|
|
beaad0a722 | ||
|
|
a39639306b | ||
|
|
7f135e0f6d | ||
|
|
095a1f0db0 | ||
|
|
e9b8320d5f | ||
|
|
783344c875 | ||
|
|
7ea1bac4ef | ||
|
|
59111e8cf3 | ||
|
|
36526928df | ||
|
|
5e0e6772d5 | ||
|
|
df166ac689 | ||
|
|
d55e3de3bc | ||
|
|
5dda39e588 | ||
|
|
d415f3a3d1 | ||
|
|
a64e1b579b | ||
|
|
9b977b9e4d | ||
|
|
b124397613 | ||
|
|
afe5a03aeb | ||
|
|
101926ca35 | ||
|
|
85abbf2555 | ||
|
|
aa4c229607 | ||
|
|
ab30b99b6e | ||
|
|
0c7fcffa01 | ||
|
|
de87624a72 | ||
|
|
7a3fcd37d5 | ||
|
|
e7e65831a7 | ||
|
|
9aee01d5a0 | ||
|
|
b9f773c1d9 | ||
|
|
077c68e4c4 | ||
|
|
9264f391cb | ||
|
|
597a2d806f | ||
|
|
d8cf488449 | ||
|
|
70c187ea40 | ||
|
|
b64968d503 | ||
|
|
e7853656c3 | ||
|
|
65c0c92c02 | ||
|
|
978d46739d | ||
|
|
bce0c51d37 | ||
|
|
9e9fc01b52 | ||
|
|
7dd6d2b13c | ||
|
|
919295e9ed | ||
|
|
5b854d700e | ||
|
|
8c0ecedda4 | ||
|
|
3898860478 | ||
|
|
d1126c02a9 | ||
|
|
a54e70b4e0 | ||
|
|
bf10b45bbc | ||
|
|
dd19d81e49 | ||
|
|
5267b0ad82 | ||
|
|
b678b58524 | ||
|
|
ea6c63edcf | ||
|
|
655e9f4685 | ||
|
|
243d01c228 | ||
|
|
7c97b8bfb3 | ||
|
|
ff4794ffec | ||
|
|
adcaf502a6 | ||
|
|
71771c7d3a | ||
|
|
9208ed1022 | ||
|
|
16af9add38 | ||
|
|
d371f0ed28 | ||
|
|
3f88e85aac | ||
|
|
d03ca958ed | ||
|
|
950c4e5936 | ||
|
|
cc0a864486 | ||
|
|
0ca6ebdfad | ||
|
|
14eb37b9aa | ||
|
|
be6acd6f24 | ||
|
|
6307bd1ad8 | ||
|
|
8b4e8fd783 | ||
|
|
d4e4e446b0 | ||
|
|
2ca0ee7d19 | ||
|
|
2a5e1f4838 | ||
|
|
65ca7e2df3 | ||
|
|
f8d095c9b9 | ||
|
|
6b1b1b14f2 | ||
|
|
7542c71dc4 | ||
|
|
74b27dfeb4 | ||
|
|
3d9c0b7b50 | ||
|
|
886d01405f | ||
|
|
8c4865215b | ||
|
|
9c8e5645b4 | ||
|
|
525912ae15 | ||
|
|
712a058872 | ||
|
|
dcd976ae06 | ||
|
|
b65f6399df | ||
|
|
96b9cd3fa5 | ||
|
|
90449d2bb5 | ||
|
|
9b65b82ccb |
515 changed files with 20163 additions and 8952 deletions
|
|
@ -15,6 +15,18 @@ DB_PASSWORD=password
|
||||||
DB_HOST=host.docker.internal
|
DB_HOST=host.docker.internal
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Read/write replicas (optional). Set DB_READ_HOST to enable the read/write split.
|
||||||
|
# Hosts may be comma-separated. Port/username/password fall back to DB_* when unset.
|
||||||
|
# DB_READ_HOST=replica1,replica2
|
||||||
|
# DB_READ_PORT=5432
|
||||||
|
# DB_READ_USERNAME=coolify
|
||||||
|
# DB_READ_PASSWORD=
|
||||||
|
# DB_WRITE_HOST=
|
||||||
|
# DB_WRITE_PORT=5432
|
||||||
|
# DB_WRITE_USERNAME=coolify
|
||||||
|
# DB_WRITE_PASSWORD=
|
||||||
|
# DB_STICKY=true
|
||||||
|
|
||||||
# Ray Configuration
|
# Ray Configuration
|
||||||
# Set to true to enable Ray
|
# Set to true to enable Ray
|
||||||
RAY_ENABLED=false
|
RAY_ENABLED=false
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,5 @@ ROOT_USERNAME=
|
||||||
ROOT_USER_EMAIL=
|
ROOT_USER_EMAIL=
|
||||||
ROOT_USER_PASSWORD=
|
ROOT_USER_PASSWORD=
|
||||||
|
|
||||||
REGISTRY_URL=ghcr.io
|
REGISTRY_URL=forgejo.mapledeploy.ca
|
||||||
|
CDN_URL=https://updates.mapledeploy.ca
|
||||||
|
|
|
||||||
104
.forgejo/workflows/build.yml
Normal file
104
.forgejo/workflows/build.yml
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
name: Build MapleDeploy Coolify Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [mapledeploy]
|
||||||
|
paths-ignore:
|
||||||
|
- "*.md"
|
||||||
|
- ".github/**"
|
||||||
|
- "templates/**"
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: forgejo.mapledeploy.ca
|
||||||
|
CDN_STORAGE_ZONE: coolify-update
|
||||||
|
CDN_PULL_ZONE_ID: "5338895"
|
||||||
|
CDN_BASE_URL: https://updates.mapledeploy.ca
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
BASE_VERSION=$(sed -n "s/.*'version' => '\([^']*\)'.*/\1/p" config/constants.php)
|
||||||
|
TIMESTAMP=$(date -u +%Y%m%d%H%M)
|
||||||
|
VERSION="${BASE_VERSION}.${TIMESTAMP}"
|
||||||
|
HELPER_VERSION=$(sed -n "s/.*'helper_version' => '\([^']*\)'.*/\1/p" config/constants.php)
|
||||||
|
REALTIME_VERSION=$(sed -n "s/.*'realtime_version' => '\([^']*\)'.*/\1/p" config/constants.php)
|
||||||
|
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "HELPER_VERSION=${HELPER_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "REALTIME_VERSION=${REALTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Building version: ${VERSION} (helper: ${HELPER_VERSION}, realtime: ${REALTIME_VERSION})"
|
||||||
|
|
||||||
|
- name: Login to Forgejo registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.FORGEJO_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.repository_owner }} --password-stdin
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: |
|
||||||
|
DOCKER_BUILDKIT=1 docker build -f docker/production/Dockerfile \
|
||||||
|
--build-arg MAPLEDEPLOY_VERSION=${{ steps.version.outputs.VERSION }} \
|
||||||
|
-t ${{ env.REGISTRY }}/${{ github.repository }}:${{ steps.version.outputs.VERSION }} \
|
||||||
|
-t ${{ env.REGISTRY }}/${{ github.repository }}:latest \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push image
|
||||||
|
run: |
|
||||||
|
docker push ${{ env.REGISTRY }}/${{ github.repository }}:${{ steps.version.outputs.VERSION }}
|
||||||
|
docker push ${{ env.REGISTRY }}/${{ github.repository }}:latest
|
||||||
|
|
||||||
|
- name: Generate versions.json
|
||||||
|
run: |
|
||||||
|
cat > versions.json <<EOF
|
||||||
|
{
|
||||||
|
"coolify": {
|
||||||
|
"v4": {
|
||||||
|
"version": "${{ steps.version.outputs.VERSION }}"
|
||||||
|
},
|
||||||
|
"helper": {
|
||||||
|
"version": "${{ steps.version.outputs.HELPER_VERSION }}"
|
||||||
|
},
|
||||||
|
"realtime": {
|
||||||
|
"version": "${{ steps.version.outputs.REALTIME_VERSION }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
echo "Generated versions.json:"
|
||||||
|
cat versions.json
|
||||||
|
|
||||||
|
- name: Install curl
|
||||||
|
run: apk add --no-cache curl
|
||||||
|
|
||||||
|
- name: Upload artifacts to Bunny CDN
|
||||||
|
run: |
|
||||||
|
STORAGE_URL="https://storage.bunnycdn.com/${{ env.CDN_STORAGE_ZONE }}/coolify"
|
||||||
|
|
||||||
|
upload() {
|
||||||
|
local file="$1"
|
||||||
|
local dest="$2"
|
||||||
|
echo "Uploading ${file} -> ${dest}"
|
||||||
|
curl -fsSL -X PUT "${STORAGE_URL}/${dest}" \
|
||||||
|
-H "AccessKey: ${{ secrets.BUNNY_CDN_STORAGE_KEY }}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${file}"
|
||||||
|
}
|
||||||
|
|
||||||
|
upload versions.json versions.json
|
||||||
|
upload scripts/upgrade.sh upgrade.sh
|
||||||
|
upload scripts/upgrade-postgres.sh upgrade-postgres.sh
|
||||||
|
upload docker-compose.yml docker-compose.yml
|
||||||
|
upload docker-compose.prod.yml docker-compose.prod.yml
|
||||||
|
upload .env.production .env.production
|
||||||
|
|
||||||
|
echo "All artifacts uploaded."
|
||||||
|
|
||||||
|
- name: Purge CDN cache
|
||||||
|
run: |
|
||||||
|
curl -fsSL -X POST "https://api.bunny.net/pullzone/${{ env.CDN_PULL_ZONE_ID }}/purgeCache" \
|
||||||
|
-H "AccessKey: ${{ secrets.BUNNY_API_KEY }}" \
|
||||||
|
-H "Content-Type: application/json"
|
||||||
|
echo "CDN cache purged."
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
name: Lock closed Issues, Discussions, and PRs
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 1 * * *'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
discussions: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lock-threads:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Lock threads after 30 days of inactivity
|
|
||||||
uses: dessant/lock-threads@v5
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
issue-inactive-days: '30'
|
|
||||||
discussion-inactive-days: '30'
|
|
||||||
pr-inactive-days: '30'
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
name: Manage Stale Issues and PRs
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 2 * * *'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
manage-stale:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Manage stale issues and PRs
|
|
||||||
uses: actions/stale@v9
|
|
||||||
id: stale
|
|
||||||
with:
|
|
||||||
stale-issue-message: 'This issue will be automatically closed in a few days if no response is received. Please provide an update with the requested information.'
|
|
||||||
stale-pr-message: 'This pull request requires attention. If no changes or response is received within the next few days, it will be automatically closed. Please update your PR or leave a comment with the requested information.'
|
|
||||||
close-issue-message: 'This issue has been automatically closed due to inactivity.'
|
|
||||||
close-pr-message: 'Thank you for your contribution. Due to inactivity, this PR was automatically closed. If you would like to continue working on this change in the future, feel free to reopen this PR or submit a new one.'
|
|
||||||
days-before-stale: 14
|
|
||||||
days-before-close: 7
|
|
||||||
stale-issue-label: '⏱︎ Stale'
|
|
||||||
stale-pr-label: '⏱︎ Stale'
|
|
||||||
only-labels: '💤 Waiting for feedback, 💤 Waiting for changes'
|
|
||||||
remove-stale-when-updated: true
|
|
||||||
operations-per-run: 100
|
|
||||||
labels-to-remove-when-unstale: '⏱︎ Stale, 💤 Waiting for feedback, 💤 Waiting for changes'
|
|
||||||
close-issue-reason: 'not_planned'
|
|
||||||
exempt-all-milestones: false
|
|
||||||
49
.github/workflows/chore-pr-comments.yml
vendored
49
.github/workflows/chore-pr-comments.yml
vendored
|
|
@ -1,49 +0,0 @@
|
||||||
name: Add comment based on label
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types:
|
|
||||||
- labeled
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
add-comment:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- label: "⚙️ Service"
|
|
||||||
body: |
|
|
||||||
Hi @${{ github.event.pull_request.user.login }}! 👋
|
|
||||||
|
|
||||||
It appears to us that you are either adding a new service or making changes to an existing one.
|
|
||||||
We kindly ask you to also review and update the **Coolify Documentation** to include this new service or it's new configuration needs.
|
|
||||||
This will help ensure that our documentation remains accurate and up-to-date for all users.
|
|
||||||
|
|
||||||
Coolify Docs Repository: https://github.com/coollabsio/coolify-docs
|
|
||||||
How to Contribute a new Service to the Docs: https://coolify.io/docs/get-started/contribute/service#adding-a-new-service-template-to-the-coolify-documentation
|
|
||||||
- label: "🛠️ Feature"
|
|
||||||
body: |
|
|
||||||
Hi @${{ github.event.pull_request.user.login }}! 👋
|
|
||||||
|
|
||||||
It appears to us that you are adding a new feature to Coolify.
|
|
||||||
We kindly ask you to also update the **Coolify Documentation** to include information about this new feature.
|
|
||||||
This will help ensure that our documentation remains accurate and up-to-date for all users.
|
|
||||||
|
|
||||||
Coolify Docs Repository: https://github.com/coollabsio/coolify-docs
|
|
||||||
How to Contribute to the Docs: https://coolify.io/docs/get-started/contribute/documentation
|
|
||||||
# - label: "✨ Enhancement"
|
|
||||||
# body: |
|
|
||||||
# It appears to us that you are making an enhancement to Coolify.
|
|
||||||
# We kindly ask you to also review and update the Coolify Documentation to include information about this enhancement if applicable.
|
|
||||||
# This will help ensure that our documentation remains accurate and up-to-date for all users.
|
|
||||||
steps:
|
|
||||||
- name: Add comment
|
|
||||||
if: github.event.label.name == matrix.label
|
|
||||||
run: gh pr comment "$NUMBER" --body "$BODY"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
GH_REPO: ${{ github.repository }}
|
|
||||||
NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
BODY: ${{ matrix.body }}
|
|
||||||
37
.github/workflows/claude.yml
vendored
37
.github/workflows/claude.yml
vendored
|
|
@ -1,37 +0,0 @@
|
||||||
name: Claude Code
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created]
|
|
||||||
issues:
|
|
||||||
types: [opened, assigned]
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude:
|
|
||||||
if: |
|
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
||||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
id-token: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code
|
|
||||||
id: claude
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
claude_args: '--model opus'
|
|
||||||
22
.github/workflows/cleanup-ghcr-untagged.yml
vendored
22
.github/workflows/cleanup-ghcr-untagged.yml
vendored
|
|
@ -1,22 +0,0 @@
|
||||||
name: Cleanup Untagged GHCR Images
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cleanup-all-packages:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
package: ['coolify', 'coolify-helper', 'coolify-realtime', 'coolify-testing-host']
|
|
||||||
steps:
|
|
||||||
- name: Delete untagged ${{ matrix.package }} images
|
|
||||||
uses: actions/delete-package-versions@v5
|
|
||||||
with:
|
|
||||||
package-name: ${{ matrix.package }}
|
|
||||||
package-type: 'container'
|
|
||||||
min-versions-to-keep: 0
|
|
||||||
delete-only-untagged-versions: 'true'
|
|
||||||
117
.github/workflows/coolify-helper-next.yml
vendored
117
.github/workflows/coolify-helper-next.yml
vendored
|
|
@ -1,117 +0,0 @@
|
||||||
name: Coolify Helper Image Development
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "next" ]
|
|
||||||
paths:
|
|
||||||
- .github/workflows/coolify-helper-next.yml
|
|
||||||
- docker/coolify-helper/Dockerfile
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITHUB_REGISTRY: ghcr.io
|
|
||||||
DOCKER_REGISTRY: docker.io
|
|
||||||
IMAGE_NAME: "coollabsio/coolify-helper"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-push:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- arch: amd64
|
|
||||||
platform: linux/amd64
|
|
||||||
runner: ubuntu-24.04
|
|
||||||
- arch: aarch64
|
|
||||||
platform: linux/aarch64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and Push Image (${{ matrix.arch }})
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/coolify-helper/Dockerfile
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
|
||||||
labels: |
|
|
||||||
coolify.managed=true
|
|
||||||
|
|
||||||
merge-manifest:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
needs: build-push
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
|
||||||
|
|
||||||
- uses: sarisia/actions-status-discord@v1
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}
|
|
||||||
|
|
||||||
116
.github/workflows/coolify-helper.yml
vendored
116
.github/workflows/coolify-helper.yml
vendored
|
|
@ -1,116 +0,0 @@
|
||||||
name: Coolify Helper Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "v4.x" ]
|
|
||||||
paths:
|
|
||||||
- .github/workflows/coolify-helper.yml
|
|
||||||
- docker/coolify-helper/Dockerfile
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITHUB_REGISTRY: ghcr.io
|
|
||||||
DOCKER_REGISTRY: docker.io
|
|
||||||
IMAGE_NAME: "coollabsio/coolify-helper"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-push:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- arch: amd64
|
|
||||||
platform: linux/amd64
|
|
||||||
runner: ubuntu-24.04
|
|
||||||
- arch: aarch64
|
|
||||||
platform: linux/aarch64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and Push Image (${{ matrix.arch }})
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/coolify-helper/Dockerfile
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
|
||||||
labels: |
|
|
||||||
coolify.managed=true
|
|
||||||
merge-manifest:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
needs: build-push
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
|
||||||
|
|
||||||
- uses: sarisia/actions-status-discord@v1
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}
|
|
||||||
|
|
||||||
122
.github/workflows/coolify-production-build.yml
vendored
122
.github/workflows/coolify-production-build.yml
vendored
|
|
@ -1,122 +0,0 @@
|
||||||
name: Production Build (v4)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["v4.x"]
|
|
||||||
paths-ignore:
|
|
||||||
- .github/workflows/coolify-helper.yml
|
|
||||||
- .github/workflows/coolify-helper-next.yml
|
|
||||||
- .github/workflows/coolify-realtime.yml
|
|
||||||
- .github/workflows/coolify-realtime-next.yml
|
|
||||||
- .github/workflows/pr-quality.yaml
|
|
||||||
- docker/coolify-helper/Dockerfile
|
|
||||||
- docker/coolify-realtime/Dockerfile
|
|
||||||
- docker/testing-host/Dockerfile
|
|
||||||
- templates/**
|
|
||||||
- CHANGELOG.md
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITHUB_REGISTRY: ghcr.io
|
|
||||||
DOCKER_REGISTRY: docker.io
|
|
||||||
IMAGE_NAME: "coollabsio/coolify"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-push:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- arch: amd64
|
|
||||||
platform: linux/amd64
|
|
||||||
runner: ubuntu-24.04
|
|
||||||
- arch: aarch64
|
|
||||||
platform: linux/aarch64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and Push Image (${{ matrix.arch }})
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/production/Dockerfile
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
|
||||||
|
|
||||||
merge-manifest:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
needs: build-push
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
|
||||||
|
|
||||||
- uses: sarisia/actions-status-discord@v1
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}
|
|
||||||
120
.github/workflows/coolify-realtime-next.yml
vendored
120
.github/workflows/coolify-realtime-next.yml
vendored
|
|
@ -1,120 +0,0 @@
|
||||||
name: Coolify Realtime Development
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "next" ]
|
|
||||||
paths:
|
|
||||||
- .github/workflows/coolify-realtime-next.yml
|
|
||||||
- docker/coolify-realtime/Dockerfile
|
|
||||||
- docker/coolify-realtime/terminal-server.js
|
|
||||||
- docker/coolify-realtime/package.json
|
|
||||||
- docker/coolify-realtime/package-lock.json
|
|
||||||
- docker/coolify-realtime/soketi-entrypoint.sh
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITHUB_REGISTRY: ghcr.io
|
|
||||||
DOCKER_REGISTRY: docker.io
|
|
||||||
IMAGE_NAME: "coollabsio/coolify-realtime"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-push:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- arch: amd64
|
|
||||||
platform: linux/amd64
|
|
||||||
runner: ubuntu-24.04
|
|
||||||
- arch: aarch64
|
|
||||||
platform: linux/aarch64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and Push Image (${{ matrix.arch }})
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/coolify-realtime/Dockerfile
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
|
||||||
labels: |
|
|
||||||
coolify.managed=true
|
|
||||||
|
|
||||||
merge-manifest:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
needs: build-push
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
|
||||||
|
|
||||||
- uses: sarisia/actions-status-discord@v1
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}
|
|
||||||
120
.github/workflows/coolify-realtime.yml
vendored
120
.github/workflows/coolify-realtime.yml
vendored
|
|
@ -1,120 +0,0 @@
|
||||||
name: Coolify Realtime
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "v4.x" ]
|
|
||||||
paths:
|
|
||||||
- .github/workflows/coolify-realtime.yml
|
|
||||||
- docker/coolify-realtime/Dockerfile
|
|
||||||
- docker/coolify-realtime/terminal-server.js
|
|
||||||
- docker/coolify-realtime/package.json
|
|
||||||
- docker/coolify-realtime/package-lock.json
|
|
||||||
- docker/coolify-realtime/soketi-entrypoint.sh
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITHUB_REGISTRY: ghcr.io
|
|
||||||
DOCKER_REGISTRY: docker.io
|
|
||||||
IMAGE_NAME: "coollabsio/coolify-realtime"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-push:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- arch: amd64
|
|
||||||
platform: linux/amd64
|
|
||||||
runner: ubuntu-24.04
|
|
||||||
- arch: aarch64
|
|
||||||
platform: linux/aarch64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and Push Image (${{ matrix.arch }})
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/coolify-realtime/Dockerfile
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
|
||||||
labels: |
|
|
||||||
coolify.managed=true
|
|
||||||
|
|
||||||
merge-manifest:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
needs: build-push
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
|
||||||
|
|
||||||
- uses: sarisia/actions-status-discord@v1
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}
|
|
||||||
134
.github/workflows/coolify-staging-build.yml
vendored
134
.github/workflows/coolify-staging-build.yml
vendored
|
|
@ -1,134 +0,0 @@
|
||||||
name: Staging Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches-ignore:
|
|
||||||
- v4.x
|
|
||||||
- v3.x
|
|
||||||
- '**v5.x**'
|
|
||||||
paths-ignore:
|
|
||||||
- .github/workflows/coolify-helper.yml
|
|
||||||
- .github/workflows/coolify-helper-next.yml
|
|
||||||
- .github/workflows/coolify-realtime.yml
|
|
||||||
- .github/workflows/coolify-realtime-next.yml
|
|
||||||
- .github/workflows/pr-quality.yaml
|
|
||||||
- docker/coolify-helper/Dockerfile
|
|
||||||
- docker/coolify-realtime/Dockerfile
|
|
||||||
- docker/testing-host/Dockerfile
|
|
||||||
- templates/**
|
|
||||||
- CHANGELOG.md
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITHUB_REGISTRY: ghcr.io
|
|
||||||
DOCKER_REGISTRY: docker.io
|
|
||||||
IMAGE_NAME: "coollabsio/coolify"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-push:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- arch: amd64
|
|
||||||
platform: linux/amd64
|
|
||||||
runner: ubuntu-24.04
|
|
||||||
- arch: aarch64
|
|
||||||
platform: linux/aarch64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Sanitize branch name for Docker tag
|
|
||||||
id: sanitize
|
|
||||||
run: |
|
|
||||||
# Replace slashes and other invalid characters with dashes
|
|
||||||
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
|
|
||||||
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and Push Image (${{ matrix.arch }})
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/production/Dockerfile
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
|
|
||||||
cache-from: |
|
|
||||||
type=gha,scope=build-${{ matrix.arch }}
|
|
||||||
type=registry,ref=${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }}
|
|
||||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }}
|
|
||||||
|
|
||||||
merge-manifest:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
needs: build-push
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Sanitize branch name for Docker tag
|
|
||||||
id: sanitize
|
|
||||||
run: |
|
|
||||||
# Replace slashes and other invalid characters with dashes
|
|
||||||
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
|
|
||||||
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
|
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
|
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
|
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
|
|
||||||
|
|
||||||
- uses: sarisia/actions-status-discord@v1
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}
|
|
||||||
104
.github/workflows/coolify-testing-host.yml
vendored
104
.github/workflows/coolify-testing-host.yml
vendored
|
|
@ -1,104 +0,0 @@
|
||||||
name: Coolify Testing Host
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "next" ]
|
|
||||||
paths:
|
|
||||||
- .github/workflows/coolify-testing-host.yml
|
|
||||||
- docker/testing-host/Dockerfile
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITHUB_REGISTRY: ghcr.io
|
|
||||||
DOCKER_REGISTRY: docker.io
|
|
||||||
IMAGE_NAME: "coollabsio/coolify-testing-host"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-push:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- arch: amd64
|
|
||||||
platform: linux/amd64
|
|
||||||
runner: ubuntu-24.04
|
|
||||||
- arch: aarch64
|
|
||||||
platform: linux/aarch64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and Push Image (${{ matrix.arch }})
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/testing-host/Dockerfile
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
|
|
||||||
labels: |
|
|
||||||
coolify.managed=true
|
|
||||||
|
|
||||||
merge-manifest:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
needs: build-push
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.GITHUB_REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
|
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
|
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
|
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
|
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
|
||||||
|
|
||||||
- uses: sarisia/actions-status-discord@v1
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}
|
|
||||||
42
.github/workflows/generate-changelog.yml
vendored
42
.github/workflows/generate-changelog.yml
vendored
|
|
@ -1,42 +0,0 @@
|
||||||
name: Generate Changelog
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ v4.x ]
|
|
||||||
paths-ignore:
|
|
||||||
- .github/workflows/coolify-helper.yml
|
|
||||||
- .github/workflows/coolify-helper-next.yml
|
|
||||||
- .github/workflows/coolify-realtime.yml
|
|
||||||
- .github/workflows/coolify-realtime-next.yml
|
|
||||||
- .github/workflows/pr-quality.yaml
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
changelog:
|
|
||||||
name: Generate changelog
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Generate changelog
|
|
||||||
uses: orhun/git-cliff-action@v4
|
|
||||||
with:
|
|
||||||
config: cliff.toml
|
|
||||||
args: --verbose
|
|
||||||
env:
|
|
||||||
OUTPUT: CHANGELOG.md
|
|
||||||
GITHUB_REPO: ${{ github.repository }}
|
|
||||||
|
|
||||||
- name: Commit
|
|
||||||
run: |
|
|
||||||
git config user.name 'github-actions[bot]'
|
|
||||||
git config user.email 'github-actions[bot]@users.noreply.github.com'
|
|
||||||
git add CHANGELOG.md
|
|
||||||
git commit -m "docs: update changelog"
|
|
||||||
git push https://${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git v4.x
|
|
||||||
111
.github/workflows/pr-quality.yaml
vendored
111
.github/workflows/pr-quality.yaml
vendored
|
|
@ -1,111 +0,0 @@
|
||||||
name: PR Quality
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: read
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, reopened]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
pr-quality:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: peakoss/anti-slop@v0
|
|
||||||
with:
|
|
||||||
# General Settings
|
|
||||||
max-failures: 4
|
|
||||||
|
|
||||||
# PR Branch Checks
|
|
||||||
allowed-target-branches: "next"
|
|
||||||
blocked-target-branches: ""
|
|
||||||
allowed-source-branches: ""
|
|
||||||
blocked-source-branches: |
|
|
||||||
main
|
|
||||||
master
|
|
||||||
v4.x
|
|
||||||
|
|
||||||
# PR Quality Checks
|
|
||||||
max-negative-reactions: 0
|
|
||||||
require-maintainer-can-modify: true
|
|
||||||
|
|
||||||
# PR Title Checks
|
|
||||||
require-conventional-title: true
|
|
||||||
|
|
||||||
# PR Description Checks
|
|
||||||
require-description: true
|
|
||||||
max-description-length: 2500
|
|
||||||
max-emoji-count: 2
|
|
||||||
max-code-references: 5
|
|
||||||
require-linked-issue: false
|
|
||||||
blocked-terms: |
|
|
||||||
STRAWBERRY
|
|
||||||
🤖 Generated with Claude Code
|
|
||||||
Generated with Claude Code
|
|
||||||
blocked-issue-numbers: 8154
|
|
||||||
|
|
||||||
# PR Template Checks
|
|
||||||
require-pr-template: true
|
|
||||||
strict-pr-template-sections: "Contributor Agreement"
|
|
||||||
optional-pr-template-sections: "Issues,Preview"
|
|
||||||
max-additional-pr-template-sections: 2
|
|
||||||
|
|
||||||
# Commit Message Checks
|
|
||||||
max-commit-message-length: 500
|
|
||||||
require-conventional-commits: false
|
|
||||||
require-commit-author-match: true
|
|
||||||
blocked-commit-authors: ""
|
|
||||||
|
|
||||||
# File Checks
|
|
||||||
allowed-file-extensions: ""
|
|
||||||
allowed-paths: ""
|
|
||||||
blocked-paths: |
|
|
||||||
README.md
|
|
||||||
SECURITY.md
|
|
||||||
LICENSE
|
|
||||||
CODE_OF_CONDUCT.md
|
|
||||||
templates/service-templates-latest.json
|
|
||||||
templates/service-templates.json
|
|
||||||
require-final-newline: true
|
|
||||||
max-added-comments: 10
|
|
||||||
|
|
||||||
# User Checks
|
|
||||||
detect-spam-usernames: true
|
|
||||||
min-account-age: 30
|
|
||||||
max-daily-forks: 7
|
|
||||||
min-profile-completeness: 4
|
|
||||||
|
|
||||||
# Merge Checks
|
|
||||||
min-repo-merged-prs: 0
|
|
||||||
min-repo-merge-ratio: 0
|
|
||||||
min-global-merge-ratio: 30
|
|
||||||
global-merge-ratio-exclude-own: false
|
|
||||||
|
|
||||||
# Exemptions
|
|
||||||
exempt-draft-prs: false
|
|
||||||
exempt-bots: |
|
|
||||||
actions-user
|
|
||||||
dependabot[bot]
|
|
||||||
renovate[bot]
|
|
||||||
github-actions[bot]
|
|
||||||
exempt-users: ""
|
|
||||||
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
|
|
||||||
exempt-label: "quality/exempt"
|
|
||||||
exempt-pr-label: ""
|
|
||||||
exempt-all-milestones: false
|
|
||||||
exempt-all-pr-milestones: false
|
|
||||||
exempt-milestones: ""
|
|
||||||
exempt-pr-milestones: ""
|
|
||||||
|
|
||||||
# PR Success Actions
|
|
||||||
success-add-pr-labels: ""
|
|
||||||
|
|
||||||
# PR Failure Actions
|
|
||||||
failure-remove-pr-labels: ""
|
|
||||||
failure-remove-all-pr-labels: true
|
|
||||||
failure-add-pr-labels: "quality/rejected"
|
|
||||||
failure-pr-message: "This PR did not pass quality checks so it will be closed. If you believe this is a mistake please let us know."
|
|
||||||
close-pr: true
|
|
||||||
lock-pr: false
|
|
||||||
|
|
@ -59,6 +59,7 @@ ### Huge Sponsors
|
||||||
|
|
||||||
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
|
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
|
||||||
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
|
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
|
||||||
|
* [Seibert Group](https://seibert.link/coolifysoftware?ref=coolify.io) - Boost productivity company-wide with AI agents like Claude Code
|
||||||
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
|
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
|
||||||
* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control
|
* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control
|
||||||
|
|
||||||
|
|
@ -70,7 +71,6 @@ ### Big Sponsors
|
||||||
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
|
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
|
||||||
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
|
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
|
||||||
* [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers
|
* [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers
|
||||||
* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
|
|
||||||
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
|
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
|
||||||
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
|
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
|
||||||
* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor
|
* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ class StopApplication
|
||||||
|
|
||||||
public string $jobQueue = 'high';
|
public string $jobQueue = 'high';
|
||||||
|
|
||||||
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true)
|
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true)
|
||||||
{
|
{
|
||||||
$servers = collect([$application->destination->server]);
|
$servers = collect([$application->destination->server]);
|
||||||
if ($application?->additional_servers?->count() > 0) {
|
if ($application?->additional_servers?->count() > 0) {
|
||||||
|
|
@ -57,12 +57,17 @@ public function handle(Application $application, bool $previewDeployments = fals
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset restart tracking when application is manually stopped
|
if ($resetRestartCount) {
|
||||||
$application->update([
|
$application->update([
|
||||||
'restart_count' => 0,
|
'restart_count' => 0,
|
||||||
'last_restart_at' => null,
|
'last_restart_at' => null,
|
||||||
'last_restart_type' => null,
|
'last_restart_type' => null,
|
||||||
]);
|
]);
|
||||||
|
} else {
|
||||||
|
$application->update([
|
||||||
|
'status' => 'exited',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
ServiceStatusChanged::dispatch($application->environment->project->team->id);
|
ServiceStatusChanged::dispatch($application->environment->project->team->id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,9 @@ public function handle(StandaloneClickhouse $database)
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||||
'healthcheck' => [
|
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||||
'test' => ['CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1'],
|
'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1',
|
||||||
'interval' => '5s',
|
]),
|
||||||
'timeout' => '5s',
|
|
||||||
'retries' => 10,
|
|
||||||
'start_period' => '5s',
|
|
||||||
],
|
|
||||||
'mem_limit' => $this->database->limits_memory,
|
'mem_limit' => $this->database->limits_memory,
|
||||||
'memswap_limit' => $this->database->limits_memory_swap,
|
'memswap_limit' => $this->database->limits_memory_swap,
|
||||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||||
|
|
@ -98,6 +94,9 @@ public function handle(StandaloneClickhouse $database)
|
||||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||||
|
|
||||||
|
if (! $this->database->isHealthcheckEnabled()) {
|
||||||
|
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||||
|
}
|
||||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||||
$docker_compose_base64 = base64_encode($docker_compose);
|
$docker_compose_base64 = base64_encode($docker_compose);
|
||||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,16 @@
|
||||||
use App\Models\StandalonePostgresql;
|
use App\Models\StandalonePostgresql;
|
||||||
use App\Models\StandaloneRedis;
|
use App\Models\StandaloneRedis;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
use Lorisleiva\Actions\Decorators\JobDecorator;
|
||||||
|
|
||||||
class StartDatabase
|
class StartDatabase
|
||||||
{
|
{
|
||||||
use AsAction;
|
use AsAction;
|
||||||
|
|
||||||
public string $jobQueue = 'high';
|
public function configureJob(JobDecorator $job): void
|
||||||
|
{
|
||||||
|
$job->onQueue(deployment_queue());
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
|
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
|
||||||
{
|
{
|
||||||
|
|
@ -25,28 +29,28 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
|
||||||
return 'Server is not functional';
|
return 'Server is not functional';
|
||||||
}
|
}
|
||||||
switch ($database->getMorphClass()) {
|
switch ($database->getMorphClass()) {
|
||||||
case \App\Models\StandalonePostgresql::class:
|
case StandalonePostgresql::class:
|
||||||
$activity = StartPostgresql::run($database);
|
$activity = StartPostgresql::run($database);
|
||||||
break;
|
break;
|
||||||
case \App\Models\StandaloneRedis::class:
|
case StandaloneRedis::class:
|
||||||
$activity = StartRedis::run($database);
|
$activity = StartRedis::run($database);
|
||||||
break;
|
break;
|
||||||
case \App\Models\StandaloneMongodb::class:
|
case StandaloneMongodb::class:
|
||||||
$activity = StartMongodb::run($database);
|
$activity = StartMongodb::run($database);
|
||||||
break;
|
break;
|
||||||
case \App\Models\StandaloneMysql::class:
|
case StandaloneMysql::class:
|
||||||
$activity = StartMysql::run($database);
|
$activity = StartMysql::run($database);
|
||||||
break;
|
break;
|
||||||
case \App\Models\StandaloneMariadb::class:
|
case StandaloneMariadb::class:
|
||||||
$activity = StartMariadb::run($database);
|
$activity = StartMariadb::run($database);
|
||||||
break;
|
break;
|
||||||
case \App\Models\StandaloneKeydb::class:
|
case StandaloneKeydb::class:
|
||||||
$activity = StartKeydb::run($database);
|
$activity = StartKeydb::run($database);
|
||||||
break;
|
break;
|
||||||
case \App\Models\StandaloneDragonfly::class:
|
case StandaloneDragonfly::class:
|
||||||
$activity = StartDragonfly::run($database);
|
$activity = StartDragonfly::run($database);
|
||||||
break;
|
break;
|
||||||
case \App\Models\StandaloneClickhouse::class:
|
case StandaloneClickhouse::class:
|
||||||
$activity = StartClickhouse::run($database);
|
$activity = StartClickhouse::run($database);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,19 @@
|
||||||
use App\Models\StandaloneMysql;
|
use App\Models\StandaloneMysql;
|
||||||
use App\Models\StandalonePostgresql;
|
use App\Models\StandalonePostgresql;
|
||||||
use App\Models\StandaloneRedis;
|
use App\Models\StandaloneRedis;
|
||||||
|
use App\Notifications\Container\ContainerRestarted;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
use Lorisleiva\Actions\Decorators\JobDecorator;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class StartDatabaseProxy
|
class StartDatabaseProxy
|
||||||
{
|
{
|
||||||
use AsAction;
|
use AsAction;
|
||||||
|
|
||||||
public string $jobQueue = 'high';
|
public function configureJob(JobDecorator $job): void
|
||||||
|
{
|
||||||
|
$job->onQueue(deployment_queue());
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database)
|
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database)
|
||||||
{
|
{
|
||||||
|
|
@ -29,7 +34,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
|
||||||
$proxyContainerName = "{$database->uuid}-proxy";
|
$proxyContainerName = "{$database->uuid}-proxy";
|
||||||
$isSSLEnabled = $database->enable_ssl ?? false;
|
$isSSLEnabled = $database->enable_ssl ?? false;
|
||||||
|
|
||||||
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
if ($database->getMorphClass() === ServiceDatabase::class) {
|
||||||
$databaseType = $database->databaseType();
|
$databaseType = $database->databaseType();
|
||||||
$network = $database->service->uuid;
|
$network = $database->service->uuid;
|
||||||
$server = data_get($database, 'service.destination.server');
|
$server = data_get($database, 'service.destination.server');
|
||||||
|
|
@ -132,7 +137,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
|
||||||
?? data_get($database, 'service.environment.project.team');
|
?? data_get($database, 'service.environment.project.team');
|
||||||
|
|
||||||
$team?->notify(
|
$team?->notify(
|
||||||
new \App\Notifications\Container\ContainerRestarted(
|
new ContainerRestarted(
|
||||||
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
|
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
|
||||||
$server,
|
$server,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -106,13 +106,9 @@ public function handle(StandaloneDragonfly $database)
|
||||||
$this->database->destination->network,
|
$this->database->destination->network,
|
||||||
],
|
],
|
||||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||||
'healthcheck' => [
|
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||||
'test' => ['CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping'],
|
'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping',
|
||||||
'interval' => '5s',
|
]),
|
||||||
'timeout' => '5s',
|
|
||||||
'retries' => 10,
|
|
||||||
'start_period' => '5s',
|
|
||||||
],
|
|
||||||
'mem_limit' => $this->database->limits_memory,
|
'mem_limit' => $this->database->limits_memory,
|
||||||
'memswap_limit' => $this->database->limits_memory_swap,
|
'memswap_limit' => $this->database->limits_memory_swap,
|
||||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||||
|
|
@ -182,6 +178,9 @@ public function handle(StandaloneDragonfly $database)
|
||||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||||
|
|
||||||
|
if (! $this->database->isHealthcheckEnabled()) {
|
||||||
|
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||||
|
}
|
||||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||||
$docker_compose_base64 = base64_encode($docker_compose);
|
$docker_compose_base64 = base64_encode($docker_compose);
|
||||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||||
|
|
|
||||||
|
|
@ -108,13 +108,9 @@ public function handle(StandaloneKeydb $database)
|
||||||
$this->database->destination->network,
|
$this->database->destination->network,
|
||||||
],
|
],
|
||||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||||
'healthcheck' => [
|
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||||
'test' => ['CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping'],
|
'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping',
|
||||||
'interval' => '5s',
|
]),
|
||||||
'timeout' => '5s',
|
|
||||||
'retries' => 10,
|
|
||||||
'start_period' => '5s',
|
|
||||||
],
|
|
||||||
'mem_limit' => $this->database->limits_memory,
|
'mem_limit' => $this->database->limits_memory,
|
||||||
'memswap_limit' => $this->database->limits_memory_swap,
|
'memswap_limit' => $this->database->limits_memory_swap,
|
||||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||||
|
|
@ -197,6 +193,9 @@ public function handle(StandaloneKeydb $database)
|
||||||
// Add custom docker run options
|
// Add custom docker run options
|
||||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||||
|
if (! $this->database->isHealthcheckEnabled()) {
|
||||||
|
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||||
|
}
|
||||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||||
$docker_compose_base64 = base64_encode($docker_compose);
|
$docker_compose_base64 = base64_encode($docker_compose);
|
||||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||||
|
|
|
||||||
|
|
@ -103,13 +103,9 @@ public function handle(StandaloneMariadb $database)
|
||||||
$this->database->destination->network,
|
$this->database->destination->network,
|
||||||
],
|
],
|
||||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||||
'healthcheck' => [
|
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||||
'test' => ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'],
|
'CMD', 'healthcheck.sh', '--connect', '--innodb_initialized',
|
||||||
'interval' => '5s',
|
]),
|
||||||
'timeout' => '5s',
|
|
||||||
'retries' => 10,
|
|
||||||
'start_period' => '5s',
|
|
||||||
],
|
|
||||||
'mem_limit' => $this->database->limits_memory,
|
'mem_limit' => $this->database->limits_memory,
|
||||||
'memswap_limit' => $this->database->limits_memory_swap,
|
'memswap_limit' => $this->database->limits_memory_swap,
|
||||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||||
|
|
@ -202,6 +198,9 @@ public function handle(StandaloneMariadb $database)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->database->isHealthcheckEnabled()) {
|
||||||
|
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||||
|
}
|
||||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||||
$docker_compose_base64 = base64_encode($docker_compose);
|
$docker_compose_base64 = base64_encode($docker_compose);
|
||||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||||
|
|
|
||||||
|
|
@ -109,17 +109,11 @@ public function handle(StandaloneMongodb $database)
|
||||||
$this->database->destination->network,
|
$this->database->destination->network,
|
||||||
],
|
],
|
||||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||||
'healthcheck' => [
|
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||||
'test' => [
|
|
||||||
'CMD',
|
'CMD',
|
||||||
'echo',
|
'echo',
|
||||||
'ok',
|
'ok',
|
||||||
],
|
]),
|
||||||
'interval' => '5s',
|
|
||||||
'timeout' => '5s',
|
|
||||||
'retries' => 10,
|
|
||||||
'start_period' => '5s',
|
|
||||||
],
|
|
||||||
'mem_limit' => $this->database->limits_memory,
|
'mem_limit' => $this->database->limits_memory,
|
||||||
'memswap_limit' => $this->database->limits_memory_swap,
|
'memswap_limit' => $this->database->limits_memory_swap,
|
||||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||||
|
|
@ -253,6 +247,9 @@ public function handle(StandaloneMongodb $database)
|
||||||
$docker_compose['services'][$container_name]['command'] = $commandParts;
|
$docker_compose['services'][$container_name]['command'] = $commandParts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->database->isHealthcheckEnabled()) {
|
||||||
|
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||||
|
}
|
||||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||||
$docker_compose_base64 = base64_encode($docker_compose);
|
$docker_compose_base64 = base64_encode($docker_compose);
|
||||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||||
|
|
|
||||||
|
|
@ -103,13 +103,9 @@ public function handle(StandaloneMysql $database)
|
||||||
$this->database->destination->network,
|
$this->database->destination->network,
|
||||||
],
|
],
|
||||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||||
'healthcheck' => [
|
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||||
'test' => ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}"],
|
'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}",
|
||||||
'interval' => '5s',
|
]),
|
||||||
'timeout' => '5s',
|
|
||||||
'retries' => 10,
|
|
||||||
'start_period' => '5s',
|
|
||||||
],
|
|
||||||
'mem_limit' => $this->database->limits_memory,
|
'mem_limit' => $this->database->limits_memory,
|
||||||
'memswap_limit' => $this->database->limits_memory_swap,
|
'memswap_limit' => $this->database->limits_memory_swap,
|
||||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||||
|
|
@ -203,6 +199,9 @@ public function handle(StandaloneMysql $database)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->database->isHealthcheckEnabled()) {
|
||||||
|
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||||
|
}
|
||||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||||
$docker_compose_base64 = base64_encode($docker_compose);
|
$docker_compose_base64 = base64_encode($docker_compose);
|
||||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||||
|
|
|
||||||
|
|
@ -110,13 +110,9 @@ public function handle(StandalonePostgresql $database)
|
||||||
$this->database->destination->network,
|
$this->database->destination->network,
|
||||||
],
|
],
|
||||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||||
'healthcheck' => [
|
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||||
'test' => ['CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1'],
|
'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1',
|
||||||
'interval' => '5s',
|
]),
|
||||||
'timeout' => '5s',
|
|
||||||
'retries' => 10,
|
|
||||||
'start_period' => '5s',
|
|
||||||
],
|
|
||||||
'mem_limit' => $this->database->limits_memory,
|
'mem_limit' => $this->database->limits_memory,
|
||||||
'memswap_limit' => $this->database->limits_memory_swap,
|
'memswap_limit' => $this->database->limits_memory_swap,
|
||||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||||
|
|
@ -213,6 +209,9 @@ public function handle(StandalonePostgresql $database)
|
||||||
$docker_compose['services'][$container_name]['command'] = $command;
|
$docker_compose['services'][$container_name]['command'] = $command;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->database->isHealthcheckEnabled()) {
|
||||||
|
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||||
|
}
|
||||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||||
$docker_compose_base64 = base64_encode($docker_compose);
|
$docker_compose_base64 = base64_encode($docker_compose);
|
||||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||||
|
|
|
||||||
|
|
@ -105,17 +105,11 @@ public function handle(StandaloneRedis $database)
|
||||||
$this->database->destination->network,
|
$this->database->destination->network,
|
||||||
],
|
],
|
||||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||||
'healthcheck' => [
|
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||||
'test' => [
|
|
||||||
'CMD-SHELL',
|
'CMD-SHELL',
|
||||||
'redis-cli',
|
'redis-cli',
|
||||||
'ping',
|
'ping',
|
||||||
],
|
]),
|
||||||
'interval' => '5s',
|
|
||||||
'timeout' => '5s',
|
|
||||||
'retries' => 10,
|
|
||||||
'start_period' => '5s',
|
|
||||||
],
|
|
||||||
'mem_limit' => $this->database->limits_memory,
|
'mem_limit' => $this->database->limits_memory,
|
||||||
'memswap_limit' => $this->database->limits_memory_swap,
|
'memswap_limit' => $this->database->limits_memory_swap,
|
||||||
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
'mem_swappiness' => $this->database->limits_memory_swappiness,
|
||||||
|
|
@ -194,6 +188,9 @@ public function handle(StandaloneRedis $database)
|
||||||
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
|
||||||
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
|
||||||
|
|
||||||
|
if (! $this->database->isHealthcheckEnabled()) {
|
||||||
|
unset($docker_compose['services'][$container_name]['healthcheck']);
|
||||||
|
}
|
||||||
$docker_compose = Yaml::dump($docker_compose, 10);
|
$docker_compose = Yaml::dump($docker_compose, 10);
|
||||||
$docker_compose_base64 = base64_encode($docker_compose);
|
$docker_compose_base64 = base64_encode($docker_compose);
|
||||||
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Actions\Docker;
|
namespace App\Actions\Docker;
|
||||||
|
|
||||||
|
use App\Actions\Application\StopApplication;
|
||||||
use App\Actions\Database\StartDatabaseProxy;
|
use App\Actions\Database\StartDatabaseProxy;
|
||||||
use App\Actions\Database\StopDatabaseProxy;
|
use App\Actions\Database\StopDatabaseProxy;
|
||||||
use App\Actions\Shared\ComplexStatusCheck;
|
use App\Actions\Shared\ComplexStatusCheck;
|
||||||
|
|
@ -9,6 +10,7 @@
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\ServiceDatabase;
|
use App\Models\ServiceDatabase;
|
||||||
|
use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached;
|
||||||
use App\Services\ContainerStatusAggregator;
|
use App\Services\ContainerStatusAggregator;
|
||||||
use App\Traits\CalculatesExcludedStatus;
|
use App\Traits\CalculatesExcludedStatus;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
@ -464,7 +466,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap all database updates in a transaction to ensure consistency
|
// Wrap all database updates in a transaction to ensure consistency
|
||||||
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) {
|
$restartLimitReached = false;
|
||||||
|
|
||||||
|
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) {
|
||||||
$previousRestartCount = $application->restart_count ?? 0;
|
$previousRestartCount = $application->restart_count ?? 0;
|
||||||
|
|
||||||
if ($maxRestartCount > $previousRestartCount) {
|
if ($maxRestartCount > $previousRestartCount) {
|
||||||
|
|
@ -475,16 +479,10 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
||||||
'last_restart_type' => 'crash',
|
'last_restart_type' => 'crash',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Send notification
|
// Check if restart limit has been reached
|
||||||
$containerName = $application->name;
|
$maxAllowedRestarts = $application->max_restart_count ?? 0;
|
||||||
$projectUuid = data_get($application, 'environment.project.uuid');
|
if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) {
|
||||||
$environmentName = data_get($application, 'environment.name');
|
$restartLimitReached = true;
|
||||||
$applicationUuid = data_get($application, 'uuid');
|
|
||||||
|
|
||||||
if ($projectUuid && $applicationUuid && $environmentName) {
|
|
||||||
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
|
|
||||||
} else {
|
|
||||||
$url = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -499,6 +497,12 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if ($restartLimitReached) {
|
||||||
|
$application->refresh();
|
||||||
|
StopApplication::dispatch($application, false, true, false);
|
||||||
|
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
@ -44,7 +45,10 @@ public function create(array $input): User
|
||||||
'password' => Hash::make($input['password']),
|
'password' => Hash::make($input['password']),
|
||||||
]);
|
]);
|
||||||
$user->save();
|
$user->save();
|
||||||
$team = $user->teams()->first();
|
$team = $user->teams()->first() ?? Team::find(0);
|
||||||
|
if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) {
|
||||||
|
$user->teams()->attach($team, ['role' => 'owner']);
|
||||||
|
}
|
||||||
|
|
||||||
// Disable registration after first user is created
|
// Disable registration after first user is created
|
||||||
$settings = instanceSettings();
|
$settings = instanceSettings();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||||
|
|
||||||
class ResetUserPassword implements ResetsUserPasswords
|
class ResetUserPassword implements ResetsUserPasswords
|
||||||
|
|
@ -17,6 +18,13 @@ class ResetUserPassword implements ResetsUserPasswords
|
||||||
*/
|
*/
|
||||||
public function reset(User $user, array $input): void
|
public function reset(User $user, array $input): void
|
||||||
{
|
{
|
||||||
|
if ($user->isMapledeployRevoked()) {
|
||||||
|
// MapleDeploy branding: dashboard-managed revocation is restored only by mapledeploy:user:set-password.
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => [trans('passwords.user')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,8 @@ public function handle(Server $server, $fromUI = false): bool
|
||||||
foreach ($conflicts as $port => $conflict) {
|
foreach ($conflicts as $port => $conflict) {
|
||||||
if ($conflict) {
|
if ($conflict) {
|
||||||
if ($fromUI) {
|
if ($fromUI) {
|
||||||
throw new \Exception("Port $port is in use.<br>You must stop the process using this port.<br><br>Docs: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/docs'>https://coolify.io/docs</a><br>Discord: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/discord'>https://coolify.io/discord</a>");
|
// MapleDeploy branding: support links
|
||||||
|
throw new \Exception("Port $port is in use.<br>You must stop the process using this port.<br><br>Support: <a target='_blank' class='dark:text-white hover:underline' href='https://mapledeploy.ca/contact'>https://mapledeploy.ca/contact</a>");
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
|
||||||
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
|
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
|
||||||
$imagePruneCmd,
|
$imagePruneCmd,
|
||||||
'docker builder prune -af',
|
'docker builder prune -af',
|
||||||
'docker buildx prune --builder coolify-railpack -af 2>/dev/null || true',
|
"docker run --rm -v \$HOME/.docker/buildx:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true",
|
||||||
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
|
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
|
||||||
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
|
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
|
||||||
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
|
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Actions\Server;
|
|
||||||
|
|
||||||
use App\Models\Application;
|
|
||||||
use App\Models\ServiceApplication;
|
|
||||||
use App\Models\ServiceDatabase;
|
|
||||||
use App\Models\StandaloneClickhouse;
|
|
||||||
use App\Models\StandaloneDragonfly;
|
|
||||||
use App\Models\StandaloneKeydb;
|
|
||||||
use App\Models\StandaloneMariadb;
|
|
||||||
use App\Models\StandaloneMongodb;
|
|
||||||
use App\Models\StandaloneMysql;
|
|
||||||
use App\Models\StandalonePostgresql;
|
|
||||||
use App\Models\StandaloneRedis;
|
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
|
||||||
|
|
||||||
class ResourcesCheck
|
|
||||||
{
|
|
||||||
use AsAction;
|
|
||||||
|
|
||||||
public function handle()
|
|
||||||
{
|
|
||||||
$seconds = 60;
|
|
||||||
try {
|
|
||||||
Application::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
|
||||||
ServiceApplication::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
|
||||||
ServiceDatabase::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
|
||||||
StandalonePostgresql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
|
||||||
StandaloneRedis::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
|
||||||
StandaloneMongodb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
|
||||||
StandaloneMysql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
|
||||||
StandaloneMariadb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
|
||||||
StandaloneKeydb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
|
||||||
StandaloneDragonfly::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
|
||||||
StandaloneClickhouse::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return handleError($e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Actions\Server;
|
namespace App\Actions\Server;
|
||||||
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Models\Service;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
|
||||||
class StartLogDrain
|
class StartLogDrain
|
||||||
|
|
@ -201,10 +202,29 @@ public function handle(Server $server)
|
||||||
"echo 'Starting Fluent Bit'",
|
"echo 'Starting Fluent Bit'",
|
||||||
"cd $config_path && docker compose up -d",
|
"cd $config_path && docker compose up -d",
|
||||||
];
|
];
|
||||||
|
$command = array_merge($command, $this->logDrainNetworkConnectCommands($server));
|
||||||
|
|
||||||
return instant_remote_process($command, $server);
|
return instant_remote_process($command, $server);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e);
|
return handleError($e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function logDrainNetworkConnectCommands(Server $server): array
|
||||||
|
{
|
||||||
|
if (! $server->isLogDrainEnabled()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $server->services()
|
||||||
|
->with('destination')
|
||||||
|
->where('connect_to_docker_network', true)
|
||||||
|
->get()
|
||||||
|
->map(fn (Service $service) => data_get($service, 'destination.network'))
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->map(fn (string $network) => 'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true')
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
|
||||||
$endpoint = data_get($server, 'settings.sentinel_custom_url');
|
$endpoint = data_get($server, 'settings.sentinel_custom_url');
|
||||||
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
|
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
|
||||||
$mountDir = '/data/coolify/sentinel';
|
$mountDir = '/data/coolify/sentinel';
|
||||||
$image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version;
|
// MapleDeploy branding: Sentinel is not mirrored to our Forgejo registry, so pull from ghcr.io directly (upstream image)
|
||||||
|
$image = 'ghcr.io/coollabsio/sentinel:'.$version;
|
||||||
if (! $endpoint) {
|
if (! $endpoint) {
|
||||||
throw new \RuntimeException('You should set FQDN in Instance Settings.');
|
throw new \RuntimeException('You should set FQDN in Instance Settings.');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,9 +119,11 @@ private function update()
|
||||||
$latestHelperImageVersion = getHelperVersion();
|
$latestHelperImageVersion = getHelperVersion();
|
||||||
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
|
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
|
||||||
|
|
||||||
|
$registryUrl = config('constants.coolify.registry_url');
|
||||||
|
|
||||||
remote_process([
|
remote_process([
|
||||||
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
|
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
|
||||||
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
|
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion $registryUrl",
|
||||||
], $this->server);
|
], $this->server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@ class RestartService
|
||||||
|
|
||||||
public function handle(Service $service, bool $pullLatestImages)
|
public function handle(Service $service, bool $pullLatestImages)
|
||||||
{
|
{
|
||||||
StopService::run($service);
|
return StartService::run(
|
||||||
|
service: $service,
|
||||||
return StartService::run($service, $pullLatestImages);
|
pullLatestImages: $pullLatestImages,
|
||||||
|
stopBeforeStart: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,22 @@
|
||||||
|
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
use Lorisleiva\Actions\Decorators\JobDecorator;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class StartService
|
class StartService
|
||||||
{
|
{
|
||||||
use AsAction;
|
use AsAction;
|
||||||
|
|
||||||
public string $jobQueue = 'high';
|
public function configureJob(JobDecorator $job): void
|
||||||
|
{
|
||||||
|
$job->onQueue(deployment_queue());
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
|
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
|
||||||
{
|
{
|
||||||
$service->parse();
|
$service->parse();
|
||||||
if ($stopBeforeStart) {
|
if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) {
|
||||||
StopService::run(service: $service, dockerCleanup: false);
|
StopService::run(service: $service, dockerCleanup: false);
|
||||||
}
|
}
|
||||||
$service->saveComposeConfigs();
|
$service->saveComposeConfigs();
|
||||||
|
|
@ -46,7 +50,34 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
|
||||||
$commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} {$safeNetwork} {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true";
|
$commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} {$safeNetwork} {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$commands = array_merge($commands, $this->logDrainNetworkConnectCommands($service));
|
||||||
|
|
||||||
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
|
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function logDrainNetworkConnectCommands(Service $service): array
|
||||||
|
{
|
||||||
|
if (! data_get($service, 'connect_to_docker_network')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $service->destination?->server?->isLogDrainEnabled()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$network = data_get($service, 'destination.network');
|
||||||
|
|
||||||
|
if (blank($network)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool
|
||||||
|
{
|
||||||
|
return $stopBeforeStart && ! $pullLatestImages;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,9 +137,11 @@ public function execute(): array
|
||||||
|
|
||||||
// Update the new owner's role to owner
|
// Update the new owner's role to owner
|
||||||
$team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
|
$team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
|
||||||
|
RevokeUserTeamTokens::forUserTeam($newOwner, $team->id);
|
||||||
|
|
||||||
// Remove the current user from the team
|
// Remove the current user from the team
|
||||||
$team->members()->detach($this->user->id);
|
$team->members()->detach($this->user->id);
|
||||||
|
RevokeUserTeamTokens::forUserTeam($this->user, $team->id);
|
||||||
|
|
||||||
$counts['transferred']++;
|
$counts['transferred']++;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|
@ -152,6 +154,7 @@ public function execute(): array
|
||||||
foreach ($preview['to_leave'] as $team) {
|
foreach ($preview['to_leave'] as $team) {
|
||||||
try {
|
try {
|
||||||
$team->members()->detach($this->user->id);
|
$team->members()->detach($this->user->id);
|
||||||
|
RevokeUserTeamTokens::forUserTeam($this->user, $team->id);
|
||||||
$counts['left']++;
|
$counts['left']++;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage());
|
\Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage());
|
||||||
|
|
|
||||||
43
app/Actions/User/RevokeUserTeamTokens.php
Normal file
43
app/Actions/User/RevokeUserTeamTokens.php
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\User;
|
||||||
|
|
||||||
|
use App\Models\PersonalAccessToken;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class RevokeUserTeamTokens
|
||||||
|
{
|
||||||
|
public static function forUserTeam(User|int $user, int|string $teamId): int
|
||||||
|
{
|
||||||
|
return self::baseQuery()
|
||||||
|
->where('tokenable_id', self::userId($user))
|
||||||
|
->where('team_id', $teamId)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forUser(User|int $user): int
|
||||||
|
{
|
||||||
|
return self::baseQuery()
|
||||||
|
->where('tokenable_id', self::userId($user))
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forTeam(int|string $teamId): int
|
||||||
|
{
|
||||||
|
return self::baseQuery()
|
||||||
|
->where('team_id', $teamId)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function baseQuery(): Builder
|
||||||
|
{
|
||||||
|
return PersonalAccessToken::query()
|
||||||
|
->where('tokenable_type', User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function userId(User|int $user): int
|
||||||
|
{
|
||||||
|
return $user instanceof User ? $user->id : $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Casts/EncryptedArrayCast.php
Normal file
51
app/Casts/EncryptedArrayCast.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Casts;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||||
|
use Illuminate\Contracts\Encryption\DecryptException;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores an array as an encrypted JSON string at rest. Tolerates legacy
|
||||||
|
* plaintext JSON rows written before the column was encrypted, so existing
|
||||||
|
* snapshots keep decoding instead of throwing.
|
||||||
|
*
|
||||||
|
* @implements CastsAttributes<array<mixed>|null, array<mixed>|null>
|
||||||
|
*/
|
||||||
|
class EncryptedArrayCast implements CastsAttributes
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
* @return array<mixed>|null
|
||||||
|
*/
|
||||||
|
public function get(Model $model, string $key, mixed $value, array $attributes): ?array
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$value = Crypt::decryptString($value);
|
||||||
|
} catch (DecryptException) {
|
||||||
|
// Legacy plaintext JSON written before this column was encrypted.
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode((string) $value, true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Crypt::encryptString(json_encode($value, JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,9 +18,13 @@ public function handle()
|
||||||
if ($servers->count() > 0) {
|
if ($servers->count() > 0) {
|
||||||
foreach ($servers as $server) {
|
foreach ($servers as $server) {
|
||||||
echo "Cleanup unreachable server ($server->id) with name $server->name";
|
echo "Cleanup unreachable server ($server->id) with name $server->name";
|
||||||
|
if (isCloud()) {
|
||||||
$server->update([
|
$server->update([
|
||||||
'ip' => '1.2.3.4',
|
'ip' => '1.2.3.4',
|
||||||
]);
|
]);
|
||||||
|
} else {
|
||||||
|
$server->forceDisableServer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -253,7 +253,7 @@ private function restoreCoolifyDbBackup()
|
||||||
'save_s3' => false,
|
'save_s3' => false,
|
||||||
'frequency' => '0 0 * * *',
|
'frequency' => '0 0 * * *',
|
||||||
'database_id' => $database->id,
|
'database_id' => $database->id,
|
||||||
'database_type' => \App\Models\StandalonePostgresql::class,
|
'database_type' => StandalonePostgresql::class,
|
||||||
'team_id' => 0,
|
'team_id' => 0,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -264,15 +264,11 @@ private function restoreCoolifyDbBackup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MapleDeploy branding: telemetry disabled — no phone-home signal
|
||||||
private function sendAliveSignal()
|
private function sendAliveSignal()
|
||||||
{
|
{
|
||||||
$id = config('app.id');
|
// Disabled for MapleDeploy: do not send telemetry to coolify.io
|
||||||
$version = config('constants.coolify.version');
|
return;
|
||||||
try {
|
|
||||||
Http::get("https://undead.coolify.io/v4/alive?appId=$id&version=$version");
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
echo "Error in sending live signal: {$e->getMessage()}\n";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function replaceSlashInEnvironmentName()
|
private function replaceSlashInEnvironmentName()
|
||||||
|
|
|
||||||
160
app/Console/Commands/Mapledeploy/UserCreate.php
Normal file
160
app/Console/Commands/Mapledeploy/UserCreate.php
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Mapledeploy;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UserCreate extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'mapledeploy:user:create
|
||||||
|
{--email= : User email address}
|
||||||
|
{--name= : User display name}
|
||||||
|
{--admin : Create the first root admin user}
|
||||||
|
{--team-role=member : Root team role for non-admin users}';
|
||||||
|
|
||||||
|
protected $description = 'Create a Coolify user for MapleDeploy dashboard access management';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$password = $this->readPassword();
|
||||||
|
$input = [
|
||||||
|
'email' => $this->option('email'),
|
||||||
|
'name' => $this->option('name'),
|
||||||
|
'password' => $password,
|
||||||
|
'team_role' => $this->option('team-role'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$validator = Validator::make($input, [
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'password' => ['required', 'string', 'min:8'],
|
||||||
|
'team_role' => ['required', Rule::in([Role::ADMIN->value, Role::MEMBER->value])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return $this->failWith('INVALID_INPUT');
|
||||||
|
}
|
||||||
|
|
||||||
|
$input['email'] = Str::lower((string) $input['email']);
|
||||||
|
|
||||||
|
if (User::whereEmail($input['email'])->exists()) {
|
||||||
|
return $this->failWith('EMAIL_EXISTS');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('admin')) {
|
||||||
|
return $this->createAdmin($input);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->createMember($input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAdmin(array $input): int
|
||||||
|
{
|
||||||
|
if (User::count() !== 0) {
|
||||||
|
return $this->failWith('USERS_ALREADY_EXIST');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = DB::transaction(function () use ($input) {
|
||||||
|
$user = (new User)->forceFill([
|
||||||
|
'id' => 0,
|
||||||
|
'name' => $input['name'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
'password' => Hash::make($input['password']),
|
||||||
|
]);
|
||||||
|
$user->save();
|
||||||
|
$user->markEmailAsVerified();
|
||||||
|
|
||||||
|
$settings = instanceSettings();
|
||||||
|
$settings->is_registration_enabled = false;
|
||||||
|
$attributes = $settings->getAttributes();
|
||||||
|
if (array_key_exists('setup_token', $attributes)) {
|
||||||
|
$settings->setup_token = null;
|
||||||
|
}
|
||||||
|
if (array_key_exists('setup_callback_url', $attributes)) {
|
||||||
|
$settings->setup_callback_url = null;
|
||||||
|
}
|
||||||
|
$settings->save();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $this->succeedWithUser($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createMember(array $input): int
|
||||||
|
{
|
||||||
|
$rootTeam = Team::find(0);
|
||||||
|
if (! $rootTeam) {
|
||||||
|
return $this->failWith('ROOT_TEAM_MISSING');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = DB::transaction(function () use ($input, $rootTeam) {
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $input['name'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
'password' => Hash::make($input['password']),
|
||||||
|
]);
|
||||||
|
$user->markEmailAsVerified();
|
||||||
|
$this->deletePersonalTeams($user);
|
||||||
|
$user->teams()->syncWithoutDetaching([
|
||||||
|
$rootTeam->id => ['role' => $input['team_role']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $this->succeedWithUser($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deletePersonalTeams(User $user): void
|
||||||
|
{
|
||||||
|
// MapleDeploy branding: dashboard-managed users should only see the
|
||||||
|
// managed instance root team, not an empty personal Coolify team.
|
||||||
|
$personalTeams = Team::query()
|
||||||
|
->where('teams.id', '!=', 0)
|
||||||
|
->where('personal_team', true)
|
||||||
|
->whereHas('members', fn ($query) => $query->whereKey($user->id))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($personalTeams as $team) {
|
||||||
|
DB::table('team_user')
|
||||||
|
->where('team_id', $team->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->delete();
|
||||||
|
DB::table('teams')->where('id', $team->id)->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readPassword(): string
|
||||||
|
{
|
||||||
|
return rtrim((string) stream_get_contents(STDIN), "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function succeedWithUser(User $user): int
|
||||||
|
{
|
||||||
|
$this->line(json_encode([
|
||||||
|
'user' => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
'name' => $user->name,
|
||||||
|
],
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function failWith(string $code): int
|
||||||
|
{
|
||||||
|
$this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Console/Commands/Mapledeploy/UserList.php
Normal file
40
app/Console/Commands/Mapledeploy/UserList.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Mapledeploy;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class UserList extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'mapledeploy:user:list';
|
||||||
|
|
||||||
|
protected $description = 'List Coolify users for MapleDeploy dashboard access management';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$users = User::with('teams')
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->map(fn (User $user) => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
'name' => $user->name,
|
||||||
|
'created_at' => $user->created_at?->toISOString(),
|
||||||
|
'teams' => $user->teams
|
||||||
|
->map(fn ($team) => [
|
||||||
|
'id' => $team->id,
|
||||||
|
'name' => $team->name,
|
||||||
|
'role' => $team->pivot?->role,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$this->line(json_encode(['users' => $users], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Console/Commands/Mapledeploy/UserRevoke.php
Normal file
55
app/Console/Commands/Mapledeploy/UserRevoke.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Mapledeploy;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class UserRevoke extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'mapledeploy:user:revoke {user_id : Coolify user id}';
|
||||||
|
|
||||||
|
protected $description = 'Revoke a Coolify user login for MapleDeploy dashboard access management';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$userId = (int) $this->argument('user_id');
|
||||||
|
if ($userId === 0) {
|
||||||
|
return $this->failWith('CANNOT_REVOKE_ROOT_USER');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::find($userId);
|
||||||
|
if (! $user) {
|
||||||
|
return $this->failWith('USER_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make(Str::random(64)),
|
||||||
|
// MapleDeploy branding: OAuth login matches by email, so keep a
|
||||||
|
// persistent marker that the callback can reject after revocation.
|
||||||
|
'remember_token' => 'mapledeploy-revoked:'.Str::random(40),
|
||||||
|
])->save();
|
||||||
|
$user->tokens()->delete();
|
||||||
|
// MapleDeploy branding: revocation must end any active browser sessions.
|
||||||
|
DB::table('sessions')->where('user_id', $user->id)->delete();
|
||||||
|
|
||||||
|
$this->line(json_encode([
|
||||||
|
'revoked' => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
],
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function failWith(string $code): int
|
||||||
|
{
|
||||||
|
$this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
app/Console/Commands/Mapledeploy/UserSetPassword.php
Normal file
107
app/Console/Commands/Mapledeploy/UserSetPassword.php
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Mapledeploy;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class UserSetPassword extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'mapledeploy:user:set-password
|
||||||
|
{user_id : Coolify user id}
|
||||||
|
{--email= : New user email address}
|
||||||
|
{--name= : New user display name}';
|
||||||
|
|
||||||
|
protected $description = 'Set a Coolify user password for MapleDeploy dashboard access management';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$password = rtrim((string) stream_get_contents(STDIN), "\n");
|
||||||
|
$updatesOwner = $this->option('email') !== null || $this->option('name') !== null;
|
||||||
|
$input = [
|
||||||
|
'password' => $password,
|
||||||
|
'email' => $this->option('email'),
|
||||||
|
'name' => $this->option('name'),
|
||||||
|
];
|
||||||
|
$rules = ['password' => ['required', 'string', 'min:8']];
|
||||||
|
if ($updatesOwner) {
|
||||||
|
$rules['email'] = ['required', 'string', 'email', 'max:255'];
|
||||||
|
$rules['name'] = ['required', 'string', 'max:255'];
|
||||||
|
}
|
||||||
|
$validator = Validator::make($input, $rules);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return $this->failWith('INVALID_INPUT');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::find($this->argument('user_id'));
|
||||||
|
if (! $user) {
|
||||||
|
return $this->failWith('USER_NOT_FOUND');
|
||||||
|
}
|
||||||
|
$rootTeam = null;
|
||||||
|
if ((int) $user->id !== 0) {
|
||||||
|
$rootTeam = Team::find(0);
|
||||||
|
if (! $rootTeam) {
|
||||||
|
return $this->failWith('ROOT_TEAM_MISSING');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes = [
|
||||||
|
'password' => Hash::make($password),
|
||||||
|
// MapleDeploy branding: clear the revocation marker when the
|
||||||
|
// dashboard intentionally restores this Coolify login.
|
||||||
|
'remember_token' => null,
|
||||||
|
];
|
||||||
|
if ($updatesOwner) {
|
||||||
|
$email = Str::lower((string) $input['email']);
|
||||||
|
if (User::whereEmail($email)->whereKeyNot($user->id)->exists()) {
|
||||||
|
return $this->failWith('EMAIL_EXISTS');
|
||||||
|
}
|
||||||
|
// MapleDeploy branding: claiming root admin transfers the Coolify
|
||||||
|
// account identity so the previous email holder cannot recover it.
|
||||||
|
$changes['email'] = $email;
|
||||||
|
$changes['name'] = $input['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($user, $changes, $updatesOwner, $rootTeam) {
|
||||||
|
$user->forceFill($changes)->save();
|
||||||
|
if ($updatesOwner && ! $user->hasVerifiedEmail()) {
|
||||||
|
$user->markEmailAsVerified();
|
||||||
|
}
|
||||||
|
if ($rootTeam) {
|
||||||
|
// MapleDeploy branding: matching an existing Coolify user by
|
||||||
|
// email must grant the same root-team admin access as a newly
|
||||||
|
// dashboard-created user.
|
||||||
|
$user->teams()->syncWithoutDetaching([
|
||||||
|
$rootTeam->id => ['role' => Role::ADMIN->value],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// MapleDeploy branding: password resets from the dashboard should
|
||||||
|
// end browser sessions authenticated with the previous password.
|
||||||
|
DB::table('sessions')->where('user_id', $user->id)->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->line(json_encode([
|
||||||
|
'user' => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
'name' => $user->name,
|
||||||
|
],
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function failWith(string $code): int
|
||||||
|
{
|
||||||
|
$this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ class SyncBunny extends Command
|
||||||
*
|
*
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}';
|
protected $signature = 'sync:bunny {--templates} {--release} {--nightly}';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The console command description.
|
* The console command description.
|
||||||
|
|
@ -25,650 +25,6 @@ class SyncBunny extends Command
|
||||||
*/
|
*/
|
||||||
protected $description = 'Sync files to BunnyCDN';
|
protected $description = 'Sync files to BunnyCDN';
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch GitHub releases and sync to GitHub repository
|
|
||||||
*/
|
|
||||||
private function syncReleasesToGitHubRepo(): bool
|
|
||||||
{
|
|
||||||
$this->info('Fetching releases from GitHub...');
|
|
||||||
try {
|
|
||||||
$response = Http::timeout(30)
|
|
||||||
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
|
|
||||||
'per_page' => 30, // Fetch more releases for better changelog
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
|
||||||
$this->error('Failed to fetch releases from GitHub: '.$response->status());
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$releases = $response->json();
|
|
||||||
$timestamp = time();
|
|
||||||
$tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp;
|
|
||||||
$branchName = 'update-releases-'.$timestamp;
|
|
||||||
|
|
||||||
// Clone the repository
|
|
||||||
$this->info('Cloning coolify-cdn repository...');
|
|
||||||
$output = [];
|
|
||||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to clone repository: '.implode("\n", $output));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create feature branch
|
|
||||||
$this->info('Creating feature branch...');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to create branch: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write releases.json
|
|
||||||
$this->info('Writing releases.json...');
|
|
||||||
$releasesPath = "$tmpDir/json/releases.json";
|
|
||||||
$releasesDir = dirname($releasesPath);
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
if (! is_dir($releasesDir)) {
|
|
||||||
$this->info("Creating directory: $releasesDir");
|
|
||||||
if (! mkdir($releasesDir, 0755, true)) {
|
|
||||||
$this->error("Failed to create directory: $releasesDir");
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
||||||
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
|
|
||||||
|
|
||||||
if ($bytesWritten === false) {
|
|
||||||
$this->error("Failed to write releases.json to: $releasesPath");
|
|
||||||
$this->error('Possible reasons: permission denied or disk full.');
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage and commit
|
|
||||||
$this->info('Committing changes...');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info('Checking for changes...');
|
|
||||||
$statusOutput = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 2>&1', $statusOutput, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty(array_filter($statusOutput))) {
|
|
||||||
$this->info('Releases are already up to date. No changes to commit.');
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$commitMessage = 'Update releases.json with latest releases - '.date('Y-m-d H:i:s');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to commit changes: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push to remote
|
|
||||||
$this->info('Pushing branch to remote...');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to push branch: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create pull request
|
|
||||||
$this->info('Creating pull request...');
|
|
||||||
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
|
|
||||||
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
|
|
||||||
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
|
||||||
$output = [];
|
|
||||||
exec($prCommand, $output, $returnCode);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to create PR: '.implode("\n", $output));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info('Pull request created successfully!');
|
|
||||||
if (! empty($output)) {
|
|
||||||
$this->info('PR Output: '.implode("\n", $output));
|
|
||||||
}
|
|
||||||
$this->info('Total releases synced: '.count($releases));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->error('Error syncing releases: '.$e->getMessage());
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync both releases.json and versions.json to GitHub repository in one PR
|
|
||||||
*/
|
|
||||||
private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
|
|
||||||
{
|
|
||||||
$this->info('Syncing releases.json and versions.json to GitHub repository...');
|
|
||||||
try {
|
|
||||||
// 1. Fetch releases from GitHub API
|
|
||||||
$this->info('Fetching releases from GitHub API...');
|
|
||||||
$response = Http::timeout(30)
|
|
||||||
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
|
|
||||||
'per_page' => 30,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
|
||||||
$this->error('Failed to fetch releases from GitHub: '.$response->status());
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$releases = $response->json();
|
|
||||||
|
|
||||||
// 2. Read versions.json
|
|
||||||
if (! file_exists($versionsLocation)) {
|
|
||||||
$this->error("versions.json not found at: $versionsLocation");
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = file_get_contents($versionsLocation);
|
|
||||||
$versionsJson = json_decode($file, true);
|
|
||||||
$actualVersion = data_get($versionsJson, 'coolify.v4.version');
|
|
||||||
|
|
||||||
$timestamp = time();
|
|
||||||
$tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp;
|
|
||||||
$branchName = 'update-releases-and-versions-'.$timestamp;
|
|
||||||
$versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
|
|
||||||
|
|
||||||
// 3. Clone the repository
|
|
||||||
$this->info('Cloning coolify-cdn repository...');
|
|
||||||
$output = [];
|
|
||||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to clone repository: '.implode("\n", $output));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Create feature branch
|
|
||||||
$this->info('Creating feature branch...');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to create branch: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Write releases.json
|
|
||||||
$this->info('Writing releases.json...');
|
|
||||||
$releasesPath = "$tmpDir/json/releases.json";
|
|
||||||
$releasesDir = dirname($releasesPath);
|
|
||||||
|
|
||||||
if (! is_dir($releasesDir)) {
|
|
||||||
if (! mkdir($releasesDir, 0755, true)) {
|
|
||||||
$this->error("Failed to create directory: $releasesDir");
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
||||||
if (file_put_contents($releasesPath, $releasesJsonContent) === false) {
|
|
||||||
$this->error("Failed to write releases.json to: $releasesPath");
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Write versions.json
|
|
||||||
$this->info('Writing versions.json...');
|
|
||||||
$versionsPath = "$tmpDir/$versionsTargetPath";
|
|
||||||
$versionsDir = dirname($versionsPath);
|
|
||||||
|
|
||||||
if (! is_dir($versionsDir)) {
|
|
||||||
if (! mkdir($versionsDir, 0755, true)) {
|
|
||||||
$this->error("Failed to create directory: $versionsDir");
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
||||||
if (file_put_contents($versionsPath, $versionsJsonContent) === false) {
|
|
||||||
$this->error("Failed to write versions.json to: $versionsPath");
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Stage both files
|
|
||||||
$this->info('Staging changes...');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. Check for changes
|
|
||||||
$this->info('Checking for changes...');
|
|
||||||
$statusOutput = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty(array_filter($statusOutput))) {
|
|
||||||
$this->info('Both files are already up to date. No changes to commit.');
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. Commit changes
|
|
||||||
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
|
||||||
$commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to commit changes: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10. Push to remote
|
|
||||||
$this->info('Pushing branch to remote...');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to push branch: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 11. Create pull request
|
|
||||||
$this->info('Creating pull request...');
|
|
||||||
$prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
|
||||||
$prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
|
|
||||||
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
|
||||||
$output = [];
|
|
||||||
exec($prCommand, $output, $returnCode);
|
|
||||||
|
|
||||||
// 12. Clean up
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to create PR: '.implode("\n", $output));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info('Pull request created successfully!');
|
|
||||||
if (! empty($output)) {
|
|
||||||
$this->info('PR URL: '.implode("\n", $output));
|
|
||||||
}
|
|
||||||
$this->info("Version synced: $actualVersion");
|
|
||||||
$this->info('Total releases synced: '.count($releases));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->error('Error syncing to GitHub: '.$e->getMessage());
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync install.sh, docker-compose, and env files to GitHub repository via PR
|
|
||||||
*/
|
|
||||||
private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool
|
|
||||||
{
|
|
||||||
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
|
||||||
$this->info("Syncing $envLabel files to GitHub repository...");
|
|
||||||
try {
|
|
||||||
$timestamp = time();
|
|
||||||
$tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp;
|
|
||||||
$branchName = 'update-files-'.$timestamp;
|
|
||||||
|
|
||||||
// Clone the repository
|
|
||||||
$this->info('Cloning coolify-cdn repository...');
|
|
||||||
$output = [];
|
|
||||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to clone repository: '.implode("\n", $output));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create feature branch
|
|
||||||
$this->info('Creating feature branch...');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to create branch: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy each file to its target path in the CDN repo
|
|
||||||
$copiedFiles = [];
|
|
||||||
foreach ($files as $sourceFile => $targetPath) {
|
|
||||||
if (! file_exists($sourceFile)) {
|
|
||||||
$this->warn("Source file not found, skipping: $sourceFile");
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$destPath = "$tmpDir/$targetPath";
|
|
||||||
$destDir = dirname($destPath);
|
|
||||||
|
|
||||||
if (! is_dir($destDir)) {
|
|
||||||
if (! mkdir($destDir, 0755, true)) {
|
|
||||||
$this->error("Failed to create directory: $destDir");
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (copy($sourceFile, $destPath) === false) {
|
|
||||||
$this->error("Failed to copy $sourceFile to $destPath");
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$copiedFiles[] = $targetPath;
|
|
||||||
$this->info("Copied: $targetPath");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($copiedFiles)) {
|
|
||||||
$this->warn('No files were copied. Nothing to commit.');
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage all copied files
|
|
||||||
$this->info('Staging changes...');
|
|
||||||
$output = [];
|
|
||||||
$stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1';
|
|
||||||
exec($stageCmd, $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for changes
|
|
||||||
$this->info('Checking for changes...');
|
|
||||||
$statusOutput = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty(array_filter($statusOutput))) {
|
|
||||||
$this->info('All files are already up to date. No changes to commit.');
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit changes
|
|
||||||
$commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to commit changes: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push to remote
|
|
||||||
$this->info('Pushing branch to remote...');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to push branch: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create pull request
|
|
||||||
$this->info('Creating pull request...');
|
|
||||||
$prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s');
|
|
||||||
$fileList = implode("\n- ", $copiedFiles);
|
|
||||||
$prBody = "Automated update of $envLabel files:\n- $fileList";
|
|
||||||
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
|
||||||
$output = [];
|
|
||||||
exec($prCommand, $output, $returnCode);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to create PR: '.implode("\n", $output));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info('Pull request created successfully!');
|
|
||||||
if (! empty($output)) {
|
|
||||||
$this->info('PR URL: '.implode("\n", $output));
|
|
||||||
}
|
|
||||||
$this->info('Files synced: '.count($copiedFiles));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->error('Error syncing files to GitHub: '.$e->getMessage());
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync versions.json to GitHub repository via PR
|
|
||||||
*/
|
|
||||||
private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
|
|
||||||
{
|
|
||||||
$this->info('Syncing versions.json to GitHub repository...');
|
|
||||||
try {
|
|
||||||
if (! file_exists($versionsLocation)) {
|
|
||||||
$this->error("versions.json not found at: $versionsLocation");
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = file_get_contents($versionsLocation);
|
|
||||||
$json = json_decode($file, true);
|
|
||||||
$actualVersion = data_get($json, 'coolify.v4.version');
|
|
||||||
|
|
||||||
$timestamp = time();
|
|
||||||
$tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp;
|
|
||||||
$branchName = 'update-versions-'.$timestamp;
|
|
||||||
$targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
|
|
||||||
|
|
||||||
// Clone the repository
|
|
||||||
$this->info('Cloning coolify-cdn repository...');
|
|
||||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to clone repository: '.implode("\n", $output));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create feature branch
|
|
||||||
$this->info('Creating feature branch...');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to create branch: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write versions.json
|
|
||||||
$this->info('Writing versions.json...');
|
|
||||||
$versionsPath = "$tmpDir/$targetPath";
|
|
||||||
$versionsDir = dirname($versionsPath);
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
if (! is_dir($versionsDir)) {
|
|
||||||
$this->info("Creating directory: $versionsDir");
|
|
||||||
if (! mkdir($versionsDir, 0755, true)) {
|
|
||||||
$this->error("Failed to create directory: $versionsDir");
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
||||||
$bytesWritten = file_put_contents($versionsPath, $jsonContent);
|
|
||||||
|
|
||||||
if ($bytesWritten === false) {
|
|
||||||
$this->error("Failed to write versions.json to: $versionsPath");
|
|
||||||
$this->error('Possible reasons: permission denied or disk full.');
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage and commit
|
|
||||||
$this->info('Committing changes...');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info('Checking for changes...');
|
|
||||||
$statusOutput = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty(array_filter($statusOutput))) {
|
|
||||||
$this->info('versions.json is already up to date. No changes to commit.');
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
|
||||||
$commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to commit changes: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push to remote
|
|
||||||
$this->info('Pushing branch to remote...');
|
|
||||||
$output = [];
|
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to push branch: '.implode("\n", $output));
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create pull request
|
|
||||||
$this->info('Creating pull request...');
|
|
||||||
$prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
|
||||||
$prBody = "Automated update of $envLabel versions.json to version $actualVersion";
|
|
||||||
$output = [];
|
|
||||||
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
|
||||||
exec($prCommand, $output, $returnCode);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
|
||||||
|
|
||||||
if ($returnCode !== 0) {
|
|
||||||
$this->error('Failed to create PR: '.implode("\n", $output));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info('Pull request created successfully!');
|
|
||||||
if (! empty($output)) {
|
|
||||||
$this->info('PR URL: '.implode("\n", $output));
|
|
||||||
}
|
|
||||||
$this->info("Version synced: $actualVersion");
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->error('Error syncing versions.json: '.$e->getMessage());
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the console command.
|
* Execute the console command.
|
||||||
*/
|
*/
|
||||||
|
|
@ -677,8 +33,6 @@ public function handle()
|
||||||
$that = $this;
|
$that = $this;
|
||||||
$only_template = $this->option('templates');
|
$only_template = $this->option('templates');
|
||||||
$only_version = $this->option('release');
|
$only_version = $this->option('release');
|
||||||
$only_github_releases = $this->option('github-releases');
|
|
||||||
$only_github_versions = $this->option('github-versions');
|
|
||||||
$nightly = $this->option('nightly');
|
$nightly = $this->option('nightly');
|
||||||
$bunny_cdn = 'https://cdn.coollabs.io';
|
$bunny_cdn = 'https://cdn.coollabs.io';
|
||||||
$bunny_cdn_path = 'coolify';
|
$bunny_cdn_path = 'coolify';
|
||||||
|
|
@ -690,6 +44,7 @@ public function handle()
|
||||||
$compose_file_prod = 'docker-compose.prod.yml';
|
$compose_file_prod = 'docker-compose.prod.yml';
|
||||||
$install_script = 'install.sh';
|
$install_script = 'install.sh';
|
||||||
$upgrade_script = 'upgrade.sh';
|
$upgrade_script = 'upgrade.sh';
|
||||||
|
$upgrade_postgres_script = 'upgrade-postgres.sh';
|
||||||
$production_env = '.env.production';
|
$production_env = '.env.production';
|
||||||
$service_template = config('constants.services.file_name');
|
$service_template = config('constants.services.file_name');
|
||||||
$versions = 'versions.json';
|
$versions = 'versions.json';
|
||||||
|
|
@ -698,6 +53,7 @@ public function handle()
|
||||||
$compose_file_prod_location = "$parent_dir/$compose_file_prod";
|
$compose_file_prod_location = "$parent_dir/$compose_file_prod";
|
||||||
$install_script_location = "$parent_dir/scripts/install.sh";
|
$install_script_location = "$parent_dir/scripts/install.sh";
|
||||||
$upgrade_script_location = "$parent_dir/scripts/upgrade.sh";
|
$upgrade_script_location = "$parent_dir/scripts/upgrade.sh";
|
||||||
|
$upgrade_postgres_script_location = "$parent_dir/scripts/upgrade-postgres.sh";
|
||||||
$production_env_location = "$parent_dir/.env.production";
|
$production_env_location = "$parent_dir/.env.production";
|
||||||
$versions_location = "$parent_dir/$versions";
|
$versions_location = "$parent_dir/$versions";
|
||||||
|
|
||||||
|
|
@ -733,39 +89,22 @@ public function handle()
|
||||||
$compose_file_prod_location = "$parent_dir/other/nightly/$compose_file_prod";
|
$compose_file_prod_location = "$parent_dir/other/nightly/$compose_file_prod";
|
||||||
$production_env_location = "$parent_dir/other/nightly/$production_env";
|
$production_env_location = "$parent_dir/other/nightly/$production_env";
|
||||||
$upgrade_script_location = "$parent_dir/other/nightly/$upgrade_script";
|
$upgrade_script_location = "$parent_dir/other/nightly/$upgrade_script";
|
||||||
|
$upgrade_postgres_script_location = "$parent_dir/other/nightly/$upgrade_postgres_script";
|
||||||
$install_script_location = "$parent_dir/other/nightly/$install_script";
|
$install_script_location = "$parent_dir/other/nightly/$install_script";
|
||||||
$versions_location = "$parent_dir/other/nightly/$versions";
|
$versions_location = "$parent_dir/other/nightly/$versions";
|
||||||
}
|
}
|
||||||
if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) {
|
if (! $only_template && ! $only_version) {
|
||||||
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||||
$this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn.");
|
$this->info("About to sync $envLabel files to BunnyCDN.");
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
|
|
||||||
// Build file mapping for diff
|
|
||||||
if ($nightly) {
|
|
||||||
$fileMapping = [
|
|
||||||
$compose_file_location => 'docker/nightly/docker-compose.yml',
|
|
||||||
$compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
|
|
||||||
$production_env_location => 'environment/nightly/.env.production',
|
|
||||||
$upgrade_script_location => 'scripts/nightly/upgrade.sh',
|
|
||||||
$install_script_location => 'scripts/nightly/install.sh',
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
$fileMapping = [
|
|
||||||
$compose_file_location => 'docker/docker-compose.yml',
|
|
||||||
$compose_file_prod_location => 'docker/docker-compose.prod.yml',
|
|
||||||
$production_env_location => 'environment/.env.production',
|
|
||||||
$upgrade_script_location => 'scripts/upgrade.sh',
|
|
||||||
$install_script_location => 'scripts/install.sh',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// BunnyCDN file mapping (local file => CDN URL path)
|
// BunnyCDN file mapping (local file => CDN URL path)
|
||||||
$bunnyFileMapping = [
|
$bunnyFileMapping = [
|
||||||
$compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file",
|
$compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file",
|
||||||
$compose_file_prod_location => "$bunny_cdn/$bunny_cdn_path/$compose_file_prod",
|
$compose_file_prod_location => "$bunny_cdn/$bunny_cdn_path/$compose_file_prod",
|
||||||
$production_env_location => "$bunny_cdn/$bunny_cdn_path/$production_env",
|
$production_env_location => "$bunny_cdn/$bunny_cdn_path/$production_env",
|
||||||
$upgrade_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_script",
|
$upgrade_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_script",
|
||||||
|
$upgrade_postgres_script_location => "$bunny_cdn/$bunny_cdn_path/$upgrade_postgres_script",
|
||||||
$install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script",
|
$install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -812,44 +151,6 @@ public function handle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diff against GitHub coolify-cdn repo
|
|
||||||
$this->newLine();
|
|
||||||
$this->info('Fetching coolify-cdn repo to compare...');
|
|
||||||
$output = [];
|
|
||||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode);
|
|
||||||
|
|
||||||
if ($returnCode === 0) {
|
|
||||||
foreach ($fileMapping as $localFile => $cdnPath) {
|
|
||||||
$remotePath = "$diffTmpDir/repo/$cdnPath";
|
|
||||||
if (! file_exists($localFile)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! file_exists($remotePath)) {
|
|
||||||
$this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)");
|
|
||||||
$hasChanges = true;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$diffOutput = [];
|
|
||||||
exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode);
|
|
||||||
if ($diffCode !== 0) {
|
|
||||||
$hasChanges = true;
|
|
||||||
$this->newLine();
|
|
||||||
$this->info("--- GitHub: $cdnPath");
|
|
||||||
$this->info("+++ Local: $cdnPath");
|
|
||||||
foreach ($diffOutput as $line) {
|
|
||||||
if (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$this->line($line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$this->warn('Could not fetch coolify-cdn repo for diff.');
|
|
||||||
}
|
|
||||||
|
|
||||||
exec('rm -rf '.escapeshellarg($diffTmpDir));
|
exec('rm -rf '.escapeshellarg($diffTmpDir));
|
||||||
|
|
||||||
if (! $hasChanges) {
|
if (! $hasChanges) {
|
||||||
|
|
@ -881,9 +182,9 @@ public function handle()
|
||||||
return;
|
return;
|
||||||
} elseif ($only_version) {
|
} elseif ($only_version) {
|
||||||
if ($nightly) {
|
if ($nightly) {
|
||||||
$this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.');
|
$this->info('About to sync NIGHTLY versions.json to BunnyCDN.');
|
||||||
} else {
|
} else {
|
||||||
$this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.');
|
$this->info('About to sync PRODUCTION versions.json to BunnyCDN.');
|
||||||
}
|
}
|
||||||
$file = file_get_contents($versions_location);
|
$file = file_get_contents($versions_location);
|
||||||
$json = json_decode($file, true);
|
$json = json_decode($file, true);
|
||||||
|
|
@ -891,8 +192,7 @@ public function handle()
|
||||||
|
|
||||||
$this->info("Version: {$actual_version}");
|
$this->info("Version: {$actual_version}");
|
||||||
$this->info('This will:');
|
$this->info('This will:');
|
||||||
$this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)');
|
$this->info(' 1. Sync versions.json to BunnyCDN');
|
||||||
$this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json');
|
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
|
|
||||||
$confirmed = confirm('Are you sure you want to proceed?');
|
$confirmed = confirm('Are you sure you want to proceed?');
|
||||||
|
|
@ -900,8 +200,7 @@ public function handle()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Sync versions.json to BunnyCDN (deprecated but still needed)
|
$this->info('Syncing versions.json to BunnyCDN...');
|
||||||
$this->info('Step 1/2: Syncing versions.json to BunnyCDN...');
|
|
||||||
Http::pool(fn (Pool $pool) => [
|
Http::pool(fn (Pool $pool) => [
|
||||||
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
|
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
|
||||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
|
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
|
||||||
|
|
@ -909,46 +208,8 @@ public function handle()
|
||||||
$this->info('✓ versions.json uploaded & purged to BunnyCDN');
|
$this->info('✓ versions.json uploaded & purged to BunnyCDN');
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
|
|
||||||
// 2. Create GitHub PR with both releases.json and versions.json
|
|
||||||
$this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...');
|
|
||||||
$githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly);
|
|
||||||
if ($githubSuccess) {
|
|
||||||
$this->info('✓ GitHub PR created successfully with both files');
|
|
||||||
} else {
|
|
||||||
$this->error('✗ Failed to create GitHub PR');
|
|
||||||
}
|
|
||||||
$this->newLine();
|
|
||||||
|
|
||||||
$this->info('=== Summary ===');
|
$this->info('=== Summary ===');
|
||||||
$this->info('BunnyCDN sync: ✓ Complete');
|
$this->info('BunnyCDN sync: ✓ Complete');
|
||||||
$this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed'));
|
|
||||||
|
|
||||||
return;
|
|
||||||
} elseif ($only_github_releases) {
|
|
||||||
$this->info('About to sync GitHub releases to GitHub repository.');
|
|
||||||
$confirmed = confirm('Are you sure you want to sync GitHub releases?');
|
|
||||||
if (! $confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync releases to GitHub repository
|
|
||||||
$this->syncReleasesToGitHubRepo();
|
|
||||||
|
|
||||||
return;
|
|
||||||
} elseif ($only_github_versions) {
|
|
||||||
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
|
||||||
$file = file_get_contents($versions_location);
|
|
||||||
$json = json_decode($file, true);
|
|
||||||
$actual_version = data_get($json, 'coolify.v4.version');
|
|
||||||
|
|
||||||
$this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository.");
|
|
||||||
$confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?');
|
|
||||||
if (! $confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync versions.json to GitHub repository
|
|
||||||
$this->syncVersionsToGitHubRepo($versions_location, $nightly);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -958,6 +219,7 @@ public function handle()
|
||||||
$pool->storage(fileName: "$compose_file_prod_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$compose_file_prod"),
|
$pool->storage(fileName: "$compose_file_prod_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$compose_file_prod"),
|
||||||
$pool->storage(fileName: "$production_env_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$production_env"),
|
$pool->storage(fileName: "$production_env_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$production_env"),
|
||||||
$pool->storage(fileName: "$upgrade_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$upgrade_script"),
|
$pool->storage(fileName: "$upgrade_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$upgrade_script"),
|
||||||
|
$pool->storage(fileName: "$upgrade_postgres_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$upgrade_postgres_script"),
|
||||||
$pool->storage(fileName: "$install_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$install_script"),
|
$pool->storage(fileName: "$install_script_location")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$install_script"),
|
||||||
]);
|
]);
|
||||||
Http::pool(fn (Pool $pool) => [
|
Http::pool(fn (Pool $pool) => [
|
||||||
|
|
@ -965,36 +227,14 @@ public function handle()
|
||||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$compose_file_prod"),
|
$pool->purge("$bunny_cdn/$bunny_cdn_path/$compose_file_prod"),
|
||||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$production_env"),
|
$pool->purge("$bunny_cdn/$bunny_cdn_path/$production_env"),
|
||||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_script"),
|
$pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_script"),
|
||||||
|
$pool->purge("$bunny_cdn/$bunny_cdn_path/$upgrade_postgres_script"),
|
||||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"),
|
$pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"),
|
||||||
]);
|
]);
|
||||||
$this->info('All files uploaded & purged to BunnyCDN.');
|
$this->info('All files uploaded & purged to BunnyCDN.');
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
|
|
||||||
// Sync files to GitHub CDN repository via PR
|
|
||||||
$this->info('Creating GitHub PR for coolify-cdn repository...');
|
|
||||||
if ($nightly) {
|
|
||||||
$files = [
|
|
||||||
$compose_file_location => 'docker/nightly/docker-compose.yml',
|
|
||||||
$compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
|
|
||||||
$production_env_location => 'environment/nightly/.env.production',
|
|
||||||
$upgrade_script_location => 'scripts/nightly/upgrade.sh',
|
|
||||||
$install_script_location => 'scripts/nightly/install.sh',
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
$files = [
|
|
||||||
$compose_file_location => 'docker/docker-compose.yml',
|
|
||||||
$compose_file_prod_location => 'docker/docker-compose.prod.yml',
|
|
||||||
$production_env_location => 'environment/.env.production',
|
|
||||||
$upgrade_script_location => 'scripts/upgrade.sh',
|
|
||||||
$install_script_location => 'scripts/install.sh',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly);
|
|
||||||
$this->newLine();
|
|
||||||
$this->info('=== Summary ===');
|
$this->info('=== Summary ===');
|
||||||
$this->info('BunnyCDN sync: Complete');
|
$this->info('BunnyCDN sync: Complete');
|
||||||
$this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed'));
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->error('Error: '.$e->getMessage());
|
$this->error('Error: '.$e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
use App\Jobs\CheckTraefikVersionJob;
|
use App\Jobs\CheckTraefikVersionJob;
|
||||||
use App\Jobs\CleanupInstanceStuffsJob;
|
use App\Jobs\CleanupInstanceStuffsJob;
|
||||||
use App\Jobs\CleanupOrphanedPreviewContainersJob;
|
use App\Jobs\CleanupOrphanedPreviewContainersJob;
|
||||||
|
use App\Jobs\CleanupStaleMultiplexedConnections;
|
||||||
use App\Jobs\PullChangelog;
|
use App\Jobs\PullChangelog;
|
||||||
use App\Jobs\PullTemplatesFromCDN;
|
use App\Jobs\PullTemplatesFromCDN;
|
||||||
use App\Jobs\RegenerateSslCertJob;
|
use App\Jobs\RegenerateSslCertJob;
|
||||||
|
|
@ -40,7 +41,10 @@ protected function schedule(Schedule $schedule): void
|
||||||
$this->instanceTimezone = config('app.timezone');
|
$this->instanceTimezone = config('app.timezone');
|
||||||
}
|
}
|
||||||
|
|
||||||
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
|
$this->scheduleInstance->call(fn () => app(CleanupStaleMultiplexedConnections::class)->handle())
|
||||||
|
->name('cleanup:ssh-mux')
|
||||||
|
->hourly()
|
||||||
|
->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop'));
|
||||||
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
|
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
|
||||||
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
|
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
|
||||||
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
|
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
|
||||||
|
|
@ -78,7 +82,7 @@ protected function schedule(Schedule $schedule): void
|
||||||
// Scheduled Jobs (Backups & Tasks)
|
// Scheduled Jobs (Backups & Tasks)
|
||||||
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
|
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
|
||||||
|
|
||||||
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
|
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily()->onOneServer();
|
||||||
|
|
||||||
$this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer();
|
$this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Models\PrivateKey;
|
use App\Models\PrivateKey;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
@ -12,15 +13,13 @@
|
||||||
|
|
||||||
class SshMultiplexingHelper
|
class SshMultiplexingHelper
|
||||||
{
|
{
|
||||||
public static function serverSshConfiguration(Server $server)
|
public static function serverSshConfiguration(Server $server): array
|
||||||
{
|
{
|
||||||
$privateKey = PrivateKey::findOrFail($server->private_key_id);
|
$privateKey = PrivateKey::findOrFail($server->private_key_id);
|
||||||
$sshKeyLocation = $privateKey->getKeyLocation();
|
|
||||||
$muxFilename = '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'sshKeyLocation' => $sshKeyLocation,
|
'sshKeyLocation' => $privateKey->getKeyLocation(),
|
||||||
'muxFilename' => $muxFilename,
|
'muxFilename' => self::muxSocket($server),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,42 +29,41 @@ public static function ensureMultiplexedConnection(Server $server): bool
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$sshConfig = self::serverSshConfiguration($server);
|
if (self::connectionIsReusable($server)) {
|
||||||
$muxSocket = $sshConfig['muxFilename'];
|
|
||||||
|
|
||||||
// Check if connection exists
|
|
||||||
$checkCommand = "ssh -O check -o ControlPath=$muxSocket ";
|
|
||||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
|
||||||
$checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
|
||||||
}
|
|
||||||
$checkCommand .= self::escapedUserAtHost($server);
|
|
||||||
$process = Process::run($checkCommand);
|
|
||||||
|
|
||||||
if ($process->exitCode() !== 0) {
|
|
||||||
return self::establishNewMultiplexedConnection($server);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection exists, ensure we have metadata for age tracking
|
|
||||||
if (self::getConnectionAge($server) === null) {
|
|
||||||
// Existing connection but no metadata, store current time as fallback
|
|
||||||
self::storeConnectionMetadata($server);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection exists, check if it needs refresh due to age
|
|
||||||
if (self::isConnectionExpired($server)) {
|
|
||||||
return self::refreshMultiplexedConnection($server);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform health check if enabled
|
|
||||||
if (config('constants.ssh.mux_health_check_enabled')) {
|
|
||||||
if (! self::isConnectionHealthy($server)) {
|
|
||||||
return self::refreshMultiplexedConnection($server);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Cache::lock(
|
||||||
|
self::connectionLockKey($server),
|
||||||
|
config('constants.ssh.mux_lock_ttl')
|
||||||
|
)->block(config('constants.ssh.mux_lock_timeout'), function () use ($server) {
|
||||||
|
if (self::connectionIsReusable($server)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::masterConnectionExists($server)) {
|
||||||
|
return self::refreshMultiplexedConnection($server);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::establishNewMultiplexedConnection($server);
|
||||||
|
});
|
||||||
|
} catch (LockTimeoutException) {
|
||||||
|
Log::warning('SSH multiplexing lock timeout, falling back to non-multiplexed connection', [
|
||||||
|
'server' => $server->name ?? $server->ip,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('SSH multiplexing lock unavailable, falling back to non-multiplexed connection', [
|
||||||
|
'server' => $server->name ?? $server->ip,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function establishNewMultiplexedConnection(Server $server): bool
|
public static function establishNewMultiplexedConnection(Server $server): bool
|
||||||
{
|
{
|
||||||
$sshConfig = self::serverSshConfiguration($server);
|
$sshConfig = self::serverSshConfiguration($server);
|
||||||
|
|
@ -75,82 +73,68 @@ public static function establishNewMultiplexedConnection(Server $server): bool
|
||||||
$serverInterval = config('constants.ssh.server_interval');
|
$serverInterval = config('constants.ssh.server_interval');
|
||||||
$muxPersistTime = config('constants.ssh.mux_persist_time');
|
$muxPersistTime = config('constants.ssh.mux_persist_time');
|
||||||
|
|
||||||
$establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
$establishCommand = "ssh -fN -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
||||||
|
|
||||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||||
$establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
$establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||||
}
|
}
|
||||||
|
|
||||||
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
|
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
|
||||||
$establishCommand .= self::escapedUserAtHost($server);
|
$establishCommand .= self::escapedUserAtHost($server);
|
||||||
|
|
||||||
$establishProcess = Process::run($establishCommand);
|
$establishProcess = Process::run($establishCommand);
|
||||||
if ($establishProcess->exitCode() !== 0) {
|
if ($establishProcess->exitCode() !== 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store connection metadata for tracking
|
|
||||||
self::storeConnectionMetadata($server);
|
self::storeConnectionMetadata($server);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function removeMuxFile(Server $server)
|
public static function removeMuxFile(Server $server): void
|
||||||
{
|
{
|
||||||
$sshConfig = self::serverSshConfiguration($server);
|
Process::run(self::muxControlCommand($server, 'exit'));
|
||||||
$muxSocket = $sshConfig['muxFilename'];
|
|
||||||
|
|
||||||
$closeCommand = "ssh -O exit -o ControlPath=$muxSocket ";
|
|
||||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
|
||||||
$closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
|
||||||
}
|
|
||||||
$closeCommand .= self::escapedUserAtHost($server);
|
|
||||||
Process::run($closeCommand);
|
|
||||||
|
|
||||||
// Clear connection metadata from cache
|
|
||||||
self::clearConnectionMetadata($server);
|
self::clearConnectionMetadata($server);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function generateScpCommand(Server $server, string $source, string $dest)
|
public static function generateScpCommand(Server $server, string $source, string $dest): string
|
||||||
{
|
{
|
||||||
$sshConfig = self::serverSshConfiguration($server);
|
$sshConfig = self::serverSshConfiguration($server);
|
||||||
$sshKeyLocation = $sshConfig['sshKeyLocation'];
|
$sshKeyLocation = $sshConfig['sshKeyLocation'];
|
||||||
$muxSocket = $sshConfig['muxFilename'];
|
$scpCommand = 'timeout '.config('constants.ssh.command_timeout').' scp ';
|
||||||
|
|
||||||
$timeout = config('constants.ssh.command_timeout');
|
|
||||||
$muxPersistTime = config('constants.ssh.mux_persist_time');
|
|
||||||
|
|
||||||
$scp_command = "timeout $timeout scp ";
|
|
||||||
if ($server->isIpv6()) {
|
if ($server->isIpv6()) {
|
||||||
$scp_command .= '-6 ';
|
$scpCommand .= '-6 ';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self::isMultiplexingEnabled()) {
|
if (self::isMultiplexingEnabled()) {
|
||||||
try {
|
try {
|
||||||
if (self::ensureMultiplexedConnection($server)) {
|
if (self::ensureMultiplexedConnection($server)) {
|
||||||
$scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
$scpCommand .= self::multiplexingOptions($server);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [
|
Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [
|
||||||
'server' => $server->name ?? $server->ip,
|
'server' => $server->name ?? $server->ip,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
// Continue without multiplexing
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||||
$scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
$scpCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||||
}
|
}
|
||||||
|
|
||||||
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
|
$scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
|
||||||
|
|
||||||
if ($server->isIpv6()) {
|
if ($server->isIpv6()) {
|
||||||
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
|
return $scpCommand.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest);
|
||||||
} else {
|
|
||||||
$scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $scp_command;
|
return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false)
|
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
|
||||||
{
|
{
|
||||||
if ($server->settings->force_disabled) {
|
if ($server->settings->force_disabled) {
|
||||||
throw new \RuntimeException('Server is disabled.');
|
throw new \RuntimeException('Server is disabled.');
|
||||||
|
|
@ -161,40 +145,139 @@ public static function generateSshCommand(Server $server, string $command, bool
|
||||||
|
|
||||||
self::validateSshKey($server->privateKey);
|
self::validateSshKey($server->privateKey);
|
||||||
|
|
||||||
$muxSocket = $sshConfig['muxFilename'];
|
$commandTimeout = $commandTimeout ?? (int) config('constants.ssh.command_timeout');
|
||||||
|
$sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh ';
|
||||||
|
|
||||||
$timeout = config('constants.ssh.command_timeout');
|
|
||||||
$muxPersistTime = config('constants.ssh.mux_persist_time');
|
|
||||||
|
|
||||||
$ssh_command = "timeout $timeout ssh ";
|
|
||||||
|
|
||||||
$multiplexingSuccessful = false;
|
|
||||||
if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
|
if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
|
||||||
try {
|
try {
|
||||||
$multiplexingSuccessful = self::ensureMultiplexedConnection($server);
|
if (self::ensureMultiplexedConnection($server)) {
|
||||||
if ($multiplexingSuccessful) {
|
$sshCommand .= self::multiplexingOptions($server);
|
||||||
$ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
|
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Throwable $e) {
|
||||||
// Continue without multiplexing
|
Log::warning('SSH multiplexing failed, falling back to non-multiplexed connection', [
|
||||||
|
'server' => $server->name ?? $server->ip,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||||
$ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
|
$sshCommand .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
|
||||||
}
|
}
|
||||||
|
|
||||||
$ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
|
$sshCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
|
||||||
|
|
||||||
$delimiter = Hash::make($command);
|
$delimiter = base64_encode(Hash::make($command));
|
||||||
$delimiter = base64_encode($delimiter);
|
|
||||||
$command = str_replace($delimiter, '', $command);
|
$command = str_replace($delimiter, '', $command);
|
||||||
|
|
||||||
$ssh_command .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
|
return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
|
||||||
.$command.PHP_EOL
|
.$command.PHP_EOL
|
||||||
.$delimiter;
|
.$delimiter;
|
||||||
|
}
|
||||||
|
|
||||||
return $ssh_command;
|
public static function getConnectionTimeout(Server $server): int
|
||||||
|
{
|
||||||
|
$timeout = data_get($server, 'settings.connection_timeout');
|
||||||
|
|
||||||
|
return is_numeric($timeout) && (int) $timeout > 0
|
||||||
|
? (int) $timeout
|
||||||
|
: (int) config('constants.ssh.connection_timeout');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isConnectionHealthy(Server $server): bool
|
||||||
|
{
|
||||||
|
$sshConfig = self::serverSshConfiguration($server);
|
||||||
|
$muxSocket = $sshConfig['muxFilename'];
|
||||||
|
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
|
||||||
|
|
||||||
|
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
|
||||||
|
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||||
|
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||||
|
}
|
||||||
|
$healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
|
||||||
|
|
||||||
|
$process = Process::run($healthCommand);
|
||||||
|
|
||||||
|
return $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isConnectionExpired(Server $server): bool
|
||||||
|
{
|
||||||
|
$connectionAge = self::getConnectionAge($server);
|
||||||
|
$maxAge = config('constants.ssh.mux_max_age');
|
||||||
|
|
||||||
|
return $connectionAge !== null && $connectionAge > $maxAge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getConnectionAge(Server $server): ?int
|
||||||
|
{
|
||||||
|
$connectionTime = Cache::get("ssh_mux_connection_time_{$server->uuid}");
|
||||||
|
|
||||||
|
if ($connectionTime === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return time() - $connectionTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function refreshMultiplexedConnection(Server $server): bool
|
||||||
|
{
|
||||||
|
self::removeMuxFile($server);
|
||||||
|
|
||||||
|
return self::establishNewMultiplexedConnection($server);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function connectionLockKey(Server $server): string
|
||||||
|
{
|
||||||
|
return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function masterConnectionExists(Server $server): bool
|
||||||
|
{
|
||||||
|
return Process::run(self::muxControlCommand($server, 'check'))->exitCode() === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function connectionIsReusable(Server $server): bool
|
||||||
|
{
|
||||||
|
if (! self::masterConnectionExists($server)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::getConnectionAge($server) === null) {
|
||||||
|
self::storeConnectionMetadata($server);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isConnectionExpired($server)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function muxControlCommand(Server $server, string $operation): string
|
||||||
|
{
|
||||||
|
$command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' ';
|
||||||
|
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||||
|
$command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $command.self::escapedUserAtHost($server);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function multiplexingOptions(Server $server): string
|
||||||
|
{
|
||||||
|
return '-o ControlMaster=auto '
|
||||||
|
.'-o ControlPath='.self::muxSocket($server).' '
|
||||||
|
.'-o ControlPersist='.config('constants.ssh.mux_persist_time').' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function muxSocket(Server $server): string
|
||||||
|
{
|
||||||
|
return '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function escapedUserAtHost(Server $server): string
|
private static function escapedUserAtHost(Server $server): string
|
||||||
|
|
@ -231,7 +314,6 @@ private static function validateSshKey(PrivateKey $privateKey): void
|
||||||
$privateKey->storeInFileSystem();
|
$privateKey->storeInFileSystem();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure correct permissions (SSH requires 0600)
|
|
||||||
if (file_exists($keyLocation)) {
|
if (file_exists($keyLocation)) {
|
||||||
$currentPerms = fileperms($keyLocation) & 0777;
|
$currentPerms = fileperms($keyLocation) & 0777;
|
||||||
if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) {
|
if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) {
|
||||||
|
|
@ -243,15 +325,6 @@ private static function validateSshKey(PrivateKey $privateKey): void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getConnectionTimeout(Server $server): int
|
|
||||||
{
|
|
||||||
$timeout = data_get($server, 'settings.connection_timeout');
|
|
||||||
|
|
||||||
return is_numeric($timeout) && (int) $timeout > 0
|
|
||||||
? (int) $timeout
|
|
||||||
: (int) config('constants.ssh.connection_timeout');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
|
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
|
||||||
{
|
{
|
||||||
$options = "-i {$sshKeyLocation} "
|
$options = "-i {$sshKeyLocation} "
|
||||||
|
|
@ -262,90 +335,20 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati
|
||||||
.'-o RequestTTY=no '
|
.'-o RequestTTY=no '
|
||||||
.'-o LogLevel=ERROR ';
|
.'-o LogLevel=ERROR ';
|
||||||
|
|
||||||
// Bruh
|
|
||||||
if ($isScp) {
|
if ($isScp) {
|
||||||
$options .= '-P '.escapeshellarg((string) $server->port).' ';
|
return $options.'-P '.escapeshellarg((string) $server->port).' ';
|
||||||
} else {
|
|
||||||
$options .= '-p '.escapeshellarg((string) $server->port).' ';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $options;
|
return $options.'-p '.escapeshellarg((string) $server->port).' ';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the multiplexed connection is healthy by running a test command
|
|
||||||
*/
|
|
||||||
public static function isConnectionHealthy(Server $server): bool
|
|
||||||
{
|
|
||||||
$sshConfig = self::serverSshConfiguration($server);
|
|
||||||
$muxSocket = $sshConfig['muxFilename'];
|
|
||||||
$healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
|
|
||||||
|
|
||||||
$healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
|
|
||||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
|
||||||
$healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
|
||||||
}
|
|
||||||
$healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
|
|
||||||
|
|
||||||
$process = Process::run($healthCommand);
|
|
||||||
$isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
|
|
||||||
|
|
||||||
return $isHealthy;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the connection has exceeded its maximum age
|
|
||||||
*/
|
|
||||||
public static function isConnectionExpired(Server $server): bool
|
|
||||||
{
|
|
||||||
$connectionAge = self::getConnectionAge($server);
|
|
||||||
$maxAge = config('constants.ssh.mux_max_age');
|
|
||||||
|
|
||||||
return $connectionAge !== null && $connectionAge > $maxAge;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the age of the current connection in seconds
|
|
||||||
*/
|
|
||||||
public static function getConnectionAge(Server $server): ?int
|
|
||||||
{
|
|
||||||
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
|
|
||||||
$connectionTime = Cache::get($cacheKey);
|
|
||||||
|
|
||||||
if ($connectionTime === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return time() - $connectionTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh a multiplexed connection by closing and re-establishing it
|
|
||||||
*/
|
|
||||||
public static function refreshMultiplexedConnection(Server $server): bool
|
|
||||||
{
|
|
||||||
// Close existing connection
|
|
||||||
self::removeMuxFile($server);
|
|
||||||
|
|
||||||
// Establish new connection
|
|
||||||
return self::establishNewMultiplexedConnection($server);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store connection metadata when a new connection is established
|
|
||||||
*/
|
|
||||||
private static function storeConnectionMetadata(Server $server): void
|
private static function storeConnectionMetadata(Server $server): void
|
||||||
{
|
{
|
||||||
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
|
Cache::put("ssh_mux_connection_time_{$server->uuid}", time(), config('constants.ssh.mux_persist_time') + 300);
|
||||||
Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear connection metadata from cache
|
|
||||||
*/
|
|
||||||
private static function clearConnectionMetadata(Server $server): void
|
private static function clearConnectionMetadata(Server $server): void
|
||||||
{
|
{
|
||||||
$cacheKey = "ssh_mux_connection_time_{$server->uuid}";
|
Cache::forget("ssh_mux_connection_time_{$server->uuid}");
|
||||||
Cache::forget($cacheKey);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
use App\Models\PrivateKey;
|
use App\Models\PrivateKey;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Rules\DockerImageFormat;
|
||||||
use App\Rules\ValidGitBranch;
|
use App\Rules\ValidGitBranch;
|
||||||
use App\Rules\ValidGitRepositoryUrl;
|
use App\Rules\ValidGitRepositoryUrl;
|
||||||
use App\Services\DockerImageParser;
|
use App\Services\DockerImageParser;
|
||||||
|
|
@ -145,7 +146,7 @@ public function applications(Request $request)
|
||||||
mediaType: 'application/json',
|
mediaType: 'application/json',
|
||||||
schema: new OA\Schema(
|
schema: new OA\Schema(
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
|
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack'],
|
||||||
properties: [
|
properties: [
|
||||||
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
||||||
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
||||||
|
|
@ -311,7 +312,7 @@ public function create_public_application(Request $request)
|
||||||
mediaType: 'application/json',
|
mediaType: 'application/json',
|
||||||
schema: new OA\Schema(
|
schema: new OA\Schema(
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
|
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack'],
|
||||||
properties: [
|
properties: [
|
||||||
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
||||||
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
||||||
|
|
@ -477,7 +478,7 @@ public function create_private_gh_app_application(Request $request)
|
||||||
mediaType: 'application/json',
|
mediaType: 'application/json',
|
||||||
schema: new OA\Schema(
|
schema: new OA\Schema(
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
|
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack'],
|
||||||
properties: [
|
properties: [
|
||||||
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
||||||
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
||||||
|
|
@ -780,7 +781,7 @@ public function create_dockerfile_application(Request $request)
|
||||||
mediaType: 'application/json',
|
mediaType: 'application/json',
|
||||||
schema: new OA\Schema(
|
schema: new OA\Schema(
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name', 'ports_exposes'],
|
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name'],
|
||||||
properties: [
|
properties: [
|
||||||
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
||||||
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
||||||
|
|
@ -1023,7 +1024,7 @@ private function create_application(Request $request, $type)
|
||||||
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
|
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
|
||||||
'git_branch' => ['string', 'required', new ValidGitBranch],
|
'git_branch' => ['string', 'required', new ValidGitBranch],
|
||||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
|
||||||
'docker_compose_domains' => 'array|nullable',
|
'docker_compose_domains' => 'array|nullable',
|
||||||
'docker_compose_domains.*' => 'array:name,domain',
|
'docker_compose_domains.*' => 'array:name,domain',
|
||||||
'docker_compose_domains.*.name' => 'string|required',
|
'docker_compose_domains.*.name' => 'string|required',
|
||||||
|
|
@ -1229,7 +1230,7 @@ private function create_application(Request $request, $type)
|
||||||
'git_repository' => 'string|required',
|
'git_repository' => 'string|required',
|
||||||
'git_branch' => ['string', 'required', new ValidGitBranch],
|
'git_branch' => ['string', 'required', new ValidGitBranch],
|
||||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
|
||||||
'github_app_uuid' => 'string|required',
|
'github_app_uuid' => 'string|required',
|
||||||
'watch_paths' => 'string|nullable',
|
'watch_paths' => 'string|nullable',
|
||||||
'docker_compose_domains' => 'array|nullable',
|
'docker_compose_domains' => 'array|nullable',
|
||||||
|
|
@ -1469,7 +1470,7 @@ private function create_application(Request $request, $type)
|
||||||
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
|
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
|
||||||
'git_branch' => ['string', 'required', new ValidGitBranch],
|
'git_branch' => ['string', 'required', new ValidGitBranch],
|
||||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
|
||||||
'private_key_uuid' => 'string|required',
|
'private_key_uuid' => 'string|required',
|
||||||
'watch_paths' => 'string|nullable',
|
'watch_paths' => 'string|nullable',
|
||||||
'docker_compose_domains' => 'array|nullable',
|
'docker_compose_domains' => 'array|nullable',
|
||||||
|
|
@ -1790,9 +1791,9 @@ private function create_application(Request $request, $type)
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'dockerimage') {
|
} elseif ($type === 'dockerimage') {
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
'docker_registry_image_name' => 'string|required',
|
'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat],
|
||||||
'docker_registry_image_tag' => 'string',
|
'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
|
||||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
|
||||||
];
|
];
|
||||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||||
$validator = customApiValidator($request->all(), $validationRules);
|
$validator = customApiValidator($request->all(), $validationRules);
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,11 @@ public function database_by_uuid(Request $request)
|
||||||
'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'],
|
'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'],
|
||||||
'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'],
|
'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'],
|
||||||
'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'],
|
'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'],
|
||||||
|
'health_check_enabled' => ['type' => 'boolean', 'description' => 'Enable the database healthcheck probe.', 'default' => true],
|
||||||
|
'health_check_interval' => ['type' => 'integer', 'description' => 'Healthcheck interval in seconds.', 'minimum' => 1, 'default' => 15],
|
||||||
|
'health_check_timeout' => ['type' => 'integer', 'description' => 'Healthcheck timeout in seconds.', 'minimum' => 1, 'default' => 5],
|
||||||
|
'health_check_retries' => ['type' => 'integer', 'description' => 'Healthcheck retries count.', 'minimum' => 1, 'default' => 5],
|
||||||
|
'health_check_start_period' => ['type' => 'integer', 'description' => 'Healthcheck start period in seconds.', 'minimum' => 0, 'default' => 5],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -565,9 +570,17 @@ public function update_by_uuid(Request $request)
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
$allowedFields = array_merge($allowedFields, ['health_check_enabled', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period']);
|
||||||
|
$healthCheckValidator = customApiValidator($request->all(), [
|
||||||
|
'health_check_enabled' => 'boolean',
|
||||||
|
'health_check_interval' => 'integer|min:1',
|
||||||
|
'health_check_timeout' => 'integer|min:1',
|
||||||
|
'health_check_retries' => 'integer|min:1',
|
||||||
|
'health_check_start_period' => 'integer|min:0',
|
||||||
|
]);
|
||||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||||
if ($validator->fails() || ! empty($extraFields)) {
|
if ($validator->fails() || $healthCheckValidator->fails() || ! empty($extraFields)) {
|
||||||
$errors = $validator->errors();
|
$errors = $validator->errors()->merge($healthCheckValidator->errors());
|
||||||
if (! empty($extraFields)) {
|
if (! empty($extraFields)) {
|
||||||
foreach ($extraFields as $field) {
|
foreach ($extraFields as $field) {
|
||||||
$errors->add($field, 'This field is not allowed.');
|
$errors->add($field, 'This field is not allowed.');
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@
|
||||||
|
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
#[OA\Info(title: 'Coolify', version: '0.1')]
|
// MapleDeploy branding: API documentation
|
||||||
#[OA\Server(url: 'https://app.coolify.io/api/v1', description: 'Coolify Cloud API. Change the host to your own instance if you are self-hosting.')]
|
#[OA\Info(title: 'MapleDeploy', version: '0.1')]
|
||||||
|
#[OA\Server(url: '/api/v1', description: 'MapleDeploy API. Powered by Coolify.')]
|
||||||
#[OA\SecurityScheme(
|
#[OA\SecurityScheme(
|
||||||
type: 'http',
|
type: 'http',
|
||||||
scheme: 'bearer',
|
scheme: 'bearer',
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
class OtherController extends Controller
|
class OtherController extends Controller
|
||||||
|
|
@ -265,23 +264,6 @@ public function disable_mcp(Request $request)
|
||||||
return response()->json(['message' => 'MCP server disabled.'], 200);
|
return response()->json(['message' => 'MCP server disabled.'], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function feedback(Request $request)
|
|
||||||
{
|
|
||||||
$data = $request->validate([
|
|
||||||
'content' => ['required', 'string', 'min:10', 'max:2000'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$webhook_url = config('constants.webhooks.feedback_discord_webhook');
|
|
||||||
if ($webhook_url) {
|
|
||||||
Http::timeout(5)->post($webhook_url, [
|
|
||||||
'content' => $data['content'],
|
|
||||||
'allowed_mentions' => ['parse' => []],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json(['message' => 'Feedback sent.'], 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[OA\Get(
|
#[OA\Get(
|
||||||
summary: 'Healthcheck',
|
summary: 'Healthcheck',
|
||||||
description: 'Healthcheck endpoint.',
|
description: 'Healthcheck endpoint.',
|
||||||
|
|
|
||||||
167
app/Http/Controllers/Api/SentinelController.php
Normal file
167
app/Http/Controllers/Api/SentinelController.php
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Jobs\PushServerUpdateJob;
|
||||||
|
use App\Models\Server;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
class SentinelController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle a Sentinel agent metrics push.
|
||||||
|
*
|
||||||
|
* Sentinel pushes its full container list on a fixed interval (default 60s),
|
||||||
|
* even when nothing changed. To avoid dispatching one PushServerUpdateJob per
|
||||||
|
* server per minute, the job is only dispatched when the container state hash
|
||||||
|
* changes, or when the force window has elapsed.
|
||||||
|
*/
|
||||||
|
public function push(Request $request)
|
||||||
|
{
|
||||||
|
$token = $request->header('Authorization');
|
||||||
|
if (! $token) {
|
||||||
|
auditLogWebhookFailure('sentinel', 'token_missing');
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
$naked_token = str_replace('Bearer ', '', $token);
|
||||||
|
try {
|
||||||
|
$decrypted = decrypt($naked_token);
|
||||||
|
$decrypted_token = json_decode($decrypted, true);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
auditLogWebhookFailure('sentinel', 'decrypt_failed');
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Invalid token'], 401);
|
||||||
|
}
|
||||||
|
$server_uuid = data_get($decrypted_token, 'server_uuid');
|
||||||
|
if (! $server_uuid) {
|
||||||
|
auditLogWebhookFailure('sentinel', 'invalid_token_payload');
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Invalid token'], 401);
|
||||||
|
}
|
||||||
|
$server = Server::where('uuid', $server_uuid)->first();
|
||||||
|
if (! $server) {
|
||||||
|
auditLogWebhookFailure('sentinel', 'server_not_found', [
|
||||||
|
'server_uuid' => $server_uuid,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Server not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
||||||
|
auditLogWebhookFailure('sentinel', 'subscription_unpaid', [
|
||||||
|
'server_uuid' => $server->uuid,
|
||||||
|
'team_id' => $server->team_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($server->isFunctional() === false) {
|
||||||
|
auditLogWebhookFailure('sentinel', 'server_not_functional', [
|
||||||
|
'server_uuid' => $server->uuid,
|
||||||
|
'team_id' => $server->team_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Server is not functional'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($server->settings->sentinel_token !== $naked_token) {
|
||||||
|
auditLogWebhookFailure('sentinel', 'token_mismatch', [
|
||||||
|
'server_uuid' => $server->uuid,
|
||||||
|
'team_id' => $server->team_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'containers' => ['present', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json(serializeApiResponse([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
]), 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->all();
|
||||||
|
|
||||||
|
// Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping.
|
||||||
|
$server->sentinelHeartbeat();
|
||||||
|
|
||||||
|
if ($this->shouldDispatchUpdate($server, $data)) {
|
||||||
|
PushServerUpdateJob::dispatch($server, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
auditLog('sentinel.metrics_pushed', [
|
||||||
|
'server_uuid' => $server->uuid,
|
||||||
|
'team_id' => $server->team_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'ok'], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether PushServerUpdateJob should be dispatched for this push.
|
||||||
|
*
|
||||||
|
* Dispatches when: first push (no cached hash), the container state changed,
|
||||||
|
* or the force window elapsed.
|
||||||
|
*/
|
||||||
|
private function shouldDispatchUpdate(Server $server, array $data): bool
|
||||||
|
{
|
||||||
|
$hash = $this->containerStateHash($data);
|
||||||
|
$hashKey = "sentinel:push-hash:{$server->id}";
|
||||||
|
$forceKey = "sentinel:push-force:{$server->id}";
|
||||||
|
$lockKey = "sentinel:push-lock:{$server->id}";
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool {
|
||||||
|
$cachedHash = Cache::get($hashKey);
|
||||||
|
$forceActive = Cache::has($forceKey);
|
||||||
|
|
||||||
|
$shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive;
|
||||||
|
|
||||||
|
if ($shouldDispatch) {
|
||||||
|
// Day-long TTL bounds memory if a server stops pushing entirely.
|
||||||
|
Cache::put($hashKey, $hash, now()->addDay());
|
||||||
|
Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $shouldDispatch;
|
||||||
|
});
|
||||||
|
} catch (LockTimeoutException) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a stable hash of container state.
|
||||||
|
*
|
||||||
|
* Covers [name, state] only — metrics, filesystem_usage_root, and
|
||||||
|
* health_status are excluded on purpose. Disk % churns constantly, and
|
||||||
|
* health checks can flap between starting/healthy/unhealthy while the
|
||||||
|
* container lifecycle state remains unchanged. Both would otherwise defeat
|
||||||
|
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
|
||||||
|
* The force window still refreshes full state periodically. Sorted by name
|
||||||
|
* so container ordering from Sentinel does not affect the hash.
|
||||||
|
*/
|
||||||
|
private function containerStateHash(array $data): string
|
||||||
|
{
|
||||||
|
$containers = collect(data_get($data, 'containers', []))
|
||||||
|
->map(fn ($c) => [
|
||||||
|
'name' => data_get($c, 'name'),
|
||||||
|
'state' => data_get($c, 'state'),
|
||||||
|
])
|
||||||
|
->sortBy('name')
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return hash('xxh128', json_encode($containers));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Server as ModelsServer;
|
use App\Models\Server as ModelsServer;
|
||||||
use App\Rules\ValidServerIp;
|
use App\Rules\ValidServerIp;
|
||||||
|
use App\Support\ValidationPatterns;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
|
|
@ -487,10 +488,12 @@ public function create_server(Request $request)
|
||||||
'ip' => ['string', 'required', new ValidServerIp],
|
'ip' => ['string', 'required', new ValidServerIp],
|
||||||
'port' => 'integer|nullable|between:1,65535',
|
'port' => 'integer|nullable|between:1,65535',
|
||||||
'private_key_uuid' => 'string|required',
|
'private_key_uuid' => 'string|required',
|
||||||
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
'user' => ValidationPatterns::serverUsernameRules(required: false),
|
||||||
'is_build_server' => 'boolean|nullable',
|
'is_build_server' => 'boolean|nullable',
|
||||||
'instant_validate' => 'boolean|nullable',
|
'instant_validate' => 'boolean|nullable',
|
||||||
'proxy_type' => 'string|nullable',
|
'proxy_type' => 'string|nullable',
|
||||||
|
], [
|
||||||
|
...ValidationPatterns::serverUsernameMessages(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||||
|
|
@ -666,7 +669,7 @@ public function update_server(Request $request)
|
||||||
'ip' => ['string', 'nullable', new ValidServerIp],
|
'ip' => ['string', 'nullable', new ValidServerIp],
|
||||||
'port' => 'integer|nullable|between:1,65535',
|
'port' => 'integer|nullable|between:1,65535',
|
||||||
'private_key_uuid' => 'string|nullable',
|
'private_key_uuid' => 'string|nullable',
|
||||||
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
'user' => ValidationPatterns::serverUsernameRules(required: false),
|
||||||
'is_build_server' => 'boolean|nullable',
|
'is_build_server' => 'boolean|nullable',
|
||||||
'instant_validate' => 'boolean|nullable',
|
'instant_validate' => 'boolean|nullable',
|
||||||
'proxy_type' => 'string|nullable',
|
'proxy_type' => 'string|nullable',
|
||||||
|
|
@ -676,6 +679,8 @@ public function update_server(Request $request)
|
||||||
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
|
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
|
||||||
'server_disk_usage_check_frequency' => 'string',
|
'server_disk_usage_check_frequency' => 'string',
|
||||||
'connection_timeout' => 'integer|min:1|max:300',
|
'connection_timeout' => 'integer|min:1|max:300',
|
||||||
|
], [
|
||||||
|
...ValidationPatterns::serverUsernameMessages(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||||
|
|
@ -700,17 +705,17 @@ public function update_server(Request $request)
|
||||||
$validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) {
|
$validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) {
|
||||||
return str($proxyType->value)->lower();
|
return str($proxyType->value)->lower();
|
||||||
});
|
});
|
||||||
if ($validProxyTypes->contains(str($request->proxy_type)->lower())) {
|
if (! $validProxyTypes->contains(str($request->proxy_type)->lower())) {
|
||||||
$server->changeProxy($request->proxy_type, async: true);
|
|
||||||
} else {
|
|
||||||
return response()->json(['message' => 'Invalid proxy type.'], 422);
|
return response()->json(['message' => 'Invalid proxy type.'], 422);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$server->update($request->only(['name', 'description', 'ip', 'port', 'user']));
|
$updateFields = $request->only(['name', 'description', 'ip', 'port', 'user']);
|
||||||
if ($request->is_build_server) {
|
if ($request->filled('private_key_uuid')) {
|
||||||
$server->settings()->update([
|
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||||
'is_build_server' => $request->is_build_server,
|
if (! $privateKey) {
|
||||||
]);
|
return response()->json(['message' => 'Private key not found.'], 404);
|
||||||
|
}
|
||||||
|
$updateFields['private_key_id'] = $privateKey->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) {
|
if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) {
|
||||||
|
|
@ -720,11 +725,22 @@ public function update_server(Request $request)
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$server->update($updateFields);
|
||||||
|
if ($request->has('is_build_server')) {
|
||||||
|
$server->settings()->update([
|
||||||
|
'is_build_server' => $request->boolean('is_build_server'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
|
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
|
||||||
if (! empty($advancedSettings)) {
|
if (! empty($advancedSettings)) {
|
||||||
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
|
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($request->proxy_type) {
|
||||||
|
$server->changeProxy($request->proxy_type, async: true);
|
||||||
|
}
|
||||||
|
|
||||||
if ($request->instant_validate) {
|
if ($request->instant_validate) {
|
||||||
ValidateServer::dispatch($server);
|
ValidateServer::dispatch($server);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Providers\RouteServiceProvider;
|
use App\Providers\RouteServiceProvider;
|
||||||
use Illuminate\Auth\Events\Verified;
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
use Illuminate\Contracts\Encryption\DecryptException;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -78,6 +79,11 @@ public function forgot_password(Request $request)
|
||||||
return response()->json(['message' => 'Transactional emails are not active'], 400);
|
return response()->json(['message' => 'Transactional emails are not active'], 400);
|
||||||
}
|
}
|
||||||
$request->validate([Fortify::email() => 'required|email']);
|
$request->validate([Fortify::email() => 'required|email']);
|
||||||
|
$user = User::where('email', $request->input(Fortify::email()))->first();
|
||||||
|
if ($user?->isMapledeployRevoked()) {
|
||||||
|
// MapleDeploy branding: only the dashboard set-password path can restore revoked users.
|
||||||
|
return app(SuccessfulPasswordResetLinkRequestResponse::class, ['status' => Password::RESET_LINK_SENT]);
|
||||||
|
}
|
||||||
$status = Password::broker(config('fortify.passwords'))->sendResetLink(
|
$status = Password::broker(config('fortify.passwords'))->sendResetLink(
|
||||||
$request->only(Fortify::email())
|
$request->only(Fortify::email())
|
||||||
);
|
);
|
||||||
|
|
@ -98,23 +104,61 @@ public function link()
|
||||||
{
|
{
|
||||||
$token = request()->get('token');
|
$token = request()->get('token');
|
||||||
if ($token) {
|
if ($token) {
|
||||||
|
try {
|
||||||
$decrypted = Crypt::decryptString($token);
|
$decrypted = Crypt::decryptString($token);
|
||||||
$email = str($decrypted)->before('@@@');
|
} catch (DecryptException) {
|
||||||
$password = str($decrypted)->after('@@@');
|
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_contains($decrypted, '@@@')) {
|
||||||
|
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = explode('@@@', $decrypted, 3);
|
||||||
|
if (count($payload) === 3) {
|
||||||
|
[$email, $invitationUuid, $password] = $payload;
|
||||||
|
} else {
|
||||||
|
[$email, $password] = $payload;
|
||||||
|
$invitationUuid = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = Str::lower($email);
|
||||||
$user = User::whereEmail($email)->first();
|
$user = User::whereEmail($email)->first();
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
return redirect()->route('login');
|
return redirect()->route('login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$invitation = TeamInvitation::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
|
||||||
|
->where('link', request()->fullUrl())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($invitationUuid && ! $invitation) {
|
||||||
|
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($invitation && ! $invitation->isValid()) {
|
||||||
|
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||||
|
}
|
||||||
|
|
||||||
if (Hash::check($password, $user->password)) {
|
if (Hash::check($password, $user->password)) {
|
||||||
$invitation = TeamInvitation::whereEmail($email);
|
if ($invitation) {
|
||||||
if ($invitation->exists()) {
|
$team = $invitation->team;
|
||||||
$team = $invitation->first()->team;
|
if (! $user->teams()->where('team_id', $team->id)->exists()) {
|
||||||
$user->teams()->attach($team->id, ['role' => $invitation->first()->role]);
|
$user->teams()->attach($team->id, ['role' => $invitation->role]);
|
||||||
|
}
|
||||||
$invitation->delete();
|
$invitation->delete();
|
||||||
} else {
|
} else {
|
||||||
$team = $user->teams()->first();
|
// MapleDeploy branding: root-team admins should land in
|
||||||
|
// the managed instance team, not their empty personal team.
|
||||||
|
$team = $user->mapledeployPreferredTeam();
|
||||||
}
|
}
|
||||||
|
|
||||||
Auth::login($user);
|
Auth::login($user);
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make(Str::random(64)),
|
||||||
|
])->save();
|
||||||
session(['currentTeam' => $team]);
|
session(['currentTeam' => $team]);
|
||||||
|
|
||||||
return redirect()->route('dashboard');
|
return redirect()->route('dashboard');
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,12 @@ public function callback(string $provider)
|
||||||
}
|
}
|
||||||
$email = strtolower($email);
|
$email = strtolower($email);
|
||||||
$user = User::whereEmail($email)->first();
|
$user = User::whereEmail($email)->first();
|
||||||
|
// MapleDeploy branding: dashboard revocation scrambles passwords,
|
||||||
|
// clears sessions, and marks the user so email-matched OAuth cannot
|
||||||
|
// reopen access.
|
||||||
|
if ($user?->isMapledeployRevoked()) {
|
||||||
|
abort(403, 'User access has been revoked');
|
||||||
|
}
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
$settings = instanceSettings();
|
$settings = instanceSettings();
|
||||||
if (! $settings->is_registration_enabled) {
|
if (! $settings->is_registration_enabled) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Actions\Application\CleanupPreviewDeployment;
|
use App\Actions\Application\CleanupPreviewDeployment;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||||
|
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
@ -14,6 +15,7 @@
|
||||||
class Bitbucket extends Controller
|
class Bitbucket extends Controller
|
||||||
{
|
{
|
||||||
use DetectsSkipDeployCommits;
|
use DetectsSkipDeployCommits;
|
||||||
|
use MatchesManualWebhookApplications;
|
||||||
|
|
||||||
public function manual(Request $request)
|
public function manual(Request $request)
|
||||||
{
|
{
|
||||||
|
|
@ -62,8 +64,14 @@ public function manual(Request $request)
|
||||||
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
|
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
|
||||||
$commit = data_get($payload, 'pullrequest.source.commit.hash');
|
$commit = data_get($payload, 'pullrequest.source.commit.hash');
|
||||||
}
|
}
|
||||||
$applications = Application::where('git_repository', 'like', "%$full_name%");
|
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
||||||
$applications = $applications->where('git_branch', $branch)->get();
|
if ($full_name === null) {
|
||||||
|
return response([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Nothing to do. Invalid repository.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name);
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
return response([
|
return response([
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
|
|
@ -79,11 +87,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'event' => $x_bitbucket_event,
|
'event' => $x_bitbucket_event,
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Webhook secret not configured.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -97,11 +101,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'event' => $x_bitbucket_event,
|
'event' => $x_bitbucket_event,
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Invalid signature.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -114,11 +114,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'event' => $x_bitbucket_event,
|
'event' => $x_bitbucket_event,
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Invalid signature.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Webhook\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Application;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
trait MatchesManualWebhookApplications
|
||||||
|
{
|
||||||
|
protected function manualWebhookRepositoryFullName(mixed $fullName): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($fullName)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullName = trim($fullName, " \t\n\r\0\x0B/");
|
||||||
|
|
||||||
|
if ($fullName === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! preg_match('/\A[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\z/', $fullName)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->normalizeManualWebhookRepositoryPath($fullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Application>
|
||||||
|
*/
|
||||||
|
protected function manualWebhookApplications(Builder $query, string $fullName): Collection
|
||||||
|
{
|
||||||
|
return $query->get()
|
||||||
|
->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function manualWebhookRepositoryMatches(?string $gitRepository, string $fullName): bool
|
||||||
|
{
|
||||||
|
$repositoryPath = $this->canonicalManualWebhookRepository($gitRepository);
|
||||||
|
|
||||||
|
if ($repositoryPath === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Git hosts (GitHub, GitLab, Gitea, Bitbucket) treat owner/repo names
|
||||||
|
// case-insensitively, so compare the canonical paths case-insensitively.
|
||||||
|
return hash_equals(mb_strtolower($fullName), mb_strtolower($repositoryPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{status: string, message: string}
|
||||||
|
*/
|
||||||
|
protected function unauthenticatedManualWebhookFailurePayload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Invalid signature.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function canonicalManualWebhookRepository(?string $gitRepository): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($gitRepository)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$gitRepository = trim($gitRepository);
|
||||||
|
|
||||||
|
if ($gitRepository === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = null;
|
||||||
|
$parts = parse_url($gitRepository);
|
||||||
|
|
||||||
|
if (is_array($parts) && isset($parts['scheme'])) {
|
||||||
|
$path = data_get($parts, 'path');
|
||||||
|
} elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) {
|
||||||
|
$path = Str::after($gitRepository, ':');
|
||||||
|
// scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo".
|
||||||
|
// Strip the leading numeric port segment so the path matches the webhook
|
||||||
|
// payload's owner/repo, consistent with convertGitUrl() in shared.php.
|
||||||
|
$path = preg_replace('#^\d+/#', '', $path) ?? $path;
|
||||||
|
} else {
|
||||||
|
$path = $gitRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($path) || $path === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->normalizeManualWebhookRepositoryPath($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizeManualWebhookRepositoryPath(string $path): string
|
||||||
|
{
|
||||||
|
$path = trim($path);
|
||||||
|
$path = strtok($path, '?#') ?: $path;
|
||||||
|
$path = trim($path, '/');
|
||||||
|
$path = preg_replace('/\.git\z/i', '', $path) ?? $path;
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Actions\Application\CleanupPreviewDeployment;
|
use App\Actions\Application\CleanupPreviewDeployment;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||||
|
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
@ -15,6 +16,7 @@
|
||||||
class Gitea extends Controller
|
class Gitea extends Controller
|
||||||
{
|
{
|
||||||
use DetectsSkipDeployCommits;
|
use DetectsSkipDeployCommits;
|
||||||
|
use MatchesManualWebhookApplications;
|
||||||
|
|
||||||
public function manual(Request $request)
|
public function manual(Request $request)
|
||||||
{
|
{
|
||||||
|
|
@ -58,15 +60,19 @@ public function manual(Request $request)
|
||||||
if (! $branch) {
|
if (! $branch) {
|
||||||
return response('Nothing to do. No branch found in the request.');
|
return response('Nothing to do. No branch found in the request.');
|
||||||
}
|
}
|
||||||
$applications = Application::where('git_repository', 'like', "%$full_name%");
|
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
||||||
|
if ($full_name === null) {
|
||||||
|
return response('Nothing to do. Invalid repository.');
|
||||||
|
}
|
||||||
|
$applications = Application::query();
|
||||||
if ($x_gitea_event === 'push') {
|
if ($x_gitea_event === 'push') {
|
||||||
$applications = $applications->where('git_branch', $branch)->get();
|
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
|
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($x_gitea_event === 'pull_request') {
|
if ($x_gitea_event === 'pull_request') {
|
||||||
$applications = $applications->where('git_branch', $base_branch)->get();
|
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
return response("Nothing to do. No applications found with branch '$base_branch'.");
|
return response("Nothing to do. No applications found with branch '$base_branch'.");
|
||||||
}
|
}
|
||||||
|
|
@ -80,11 +86,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'event' => $x_gitea_event,
|
'event' => $x_gitea_event,
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Webhook secret not configured.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -96,11 +98,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'event' => $x_gitea_event,
|
'event' => $x_gitea_event,
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Invalid signature.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,17 @@
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||||
|
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||||
use App\Jobs\GithubAppPermissionJob;
|
use App\Jobs\GithubAppPermissionJob;
|
||||||
use App\Jobs\ProcessGithubPullRequestWebhook;
|
use App\Jobs\ProcessGithubPullRequestWebhook;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\GithubApp;
|
use App\Models\GithubApp;
|
||||||
use App\Models\PrivateKey;
|
use App\Models\PrivateKey;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
@ -18,6 +22,7 @@
|
||||||
class Github extends Controller
|
class Github extends Controller
|
||||||
{
|
{
|
||||||
use DetectsSkipDeployCommits;
|
use DetectsSkipDeployCommits;
|
||||||
|
use MatchesManualWebhookApplications;
|
||||||
|
|
||||||
public function manual(Request $request)
|
public function manual(Request $request)
|
||||||
{
|
{
|
||||||
|
|
@ -59,6 +64,7 @@ public function manual(Request $request)
|
||||||
$before_sha = data_get($payload, 'before');
|
$before_sha = data_get($payload, 'before');
|
||||||
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
|
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
|
||||||
$author_association = data_get($payload, 'pull_request.author_association');
|
$author_association = data_get($payload, 'pull_request.author_association');
|
||||||
|
$is_fork_pull_request = $this->isForkPullRequest($payload);
|
||||||
}
|
}
|
||||||
if (! in_array($x_github_event, ['push', 'pull_request'])) {
|
if (! in_array($x_github_event, ['push', 'pull_request'])) {
|
||||||
return response("Nothing to do. Event '$x_github_event' is not supported.");
|
return response("Nothing to do. Event '$x_github_event' is not supported.");
|
||||||
|
|
@ -66,15 +72,19 @@ public function manual(Request $request)
|
||||||
if (! $branch) {
|
if (! $branch) {
|
||||||
return response('Nothing to do. No branch found in the request.');
|
return response('Nothing to do. No branch found in the request.');
|
||||||
}
|
}
|
||||||
$applications = Application::where('git_repository', 'like', "%$full_name%");
|
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
||||||
|
if ($full_name === null) {
|
||||||
|
return response('Nothing to do. Invalid repository.');
|
||||||
|
}
|
||||||
|
$applications = Application::query();
|
||||||
if ($x_github_event === 'push') {
|
if ($x_github_event === 'push') {
|
||||||
$applications = $applications->where('git_branch', $branch)->get();
|
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
|
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($x_github_event === 'pull_request') {
|
if ($x_github_event === 'pull_request') {
|
||||||
$applications = $applications->where('git_branch', $base_branch)->get();
|
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
|
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
|
||||||
}
|
}
|
||||||
|
|
@ -93,11 +103,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'mode' => 'manual',
|
'mode' => 'manual',
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Webhook secret not configured.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -109,11 +115,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'mode' => 'manual',
|
'mode' => 'manual',
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Invalid signature.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -223,6 +225,7 @@ public function manual(Request $request)
|
||||||
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
|
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
|
||||||
authorAssociation: $author_association,
|
authorAssociation: $author_association,
|
||||||
fullName: $full_name,
|
fullName: $full_name,
|
||||||
|
isForkPullRequest: $is_fork_pull_request ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
|
|
@ -304,6 +307,7 @@ public function normal(Request $request)
|
||||||
$before_sha = data_get($payload, 'before');
|
$before_sha = data_get($payload, 'before');
|
||||||
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
|
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
|
||||||
$author_association = data_get($payload, 'pull_request.author_association');
|
$author_association = data_get($payload, 'pull_request.author_association');
|
||||||
|
$is_fork_pull_request = $this->isForkPullRequest($payload);
|
||||||
}
|
}
|
||||||
if (! in_array($x_github_event, ['push', 'pull_request'])) {
|
if (! in_array($x_github_event, ['push', 'pull_request'])) {
|
||||||
return response("Nothing to do. Event '$x_github_event' is not supported.");
|
return response("Nothing to do. Event '$x_github_event' is not supported.");
|
||||||
|
|
@ -435,6 +439,7 @@ public function normal(Request $request)
|
||||||
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
|
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
|
||||||
authorAssociation: $author_association,
|
authorAssociation: $author_association,
|
||||||
fullName: $full_name,
|
fullName: $full_name,
|
||||||
|
isForkPullRequest: $is_fork_pull_request ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
|
|
@ -452,20 +457,71 @@ public function normal(Request $request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether a pull_request webhook payload originates from a fork.
|
||||||
|
*
|
||||||
|
* GitHub's `author_association` is not a reliable trust signal (it grants
|
||||||
|
* CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
|
||||||
|
* detection is gated on whether the PR crosses repository boundaries.
|
||||||
|
*
|
||||||
|
* The repository id comparison is the canonical signal; the `head.repo.fork`
|
||||||
|
* flag and a case-insensitive full_name comparison are fallbacks for payloads
|
||||||
|
* where the ids are unavailable (e.g. a deleted head repository).
|
||||||
|
*/
|
||||||
|
private function isForkPullRequest(mixed $payload): bool
|
||||||
|
{
|
||||||
|
$headRepoId = data_get($payload, 'pull_request.head.repo.id');
|
||||||
|
$baseRepoId = data_get($payload, 'pull_request.base.repo.id');
|
||||||
|
|
||||||
|
if ($headRepoId !== null && $baseRepoId !== null) {
|
||||||
|
return (string) $headRepoId !== (string) $baseRepoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data_get($payload, 'pull_request.head.repo.fork') === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headRepoFullName = data_get($payload, 'pull_request.head.repo.full_name');
|
||||||
|
$baseRepoFullName = data_get($payload, 'pull_request.base.repo.full_name');
|
||||||
|
|
||||||
|
if (is_string($headRepoFullName) && is_string($baseRepoFullName)) {
|
||||||
|
return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public function redirect(Request $request)
|
public function redirect(Request $request)
|
||||||
{
|
{
|
||||||
try {
|
$code = (string) $request->query('code', '');
|
||||||
$code = $request->get('code');
|
abort_if(blank($code), 422, 'Missing GitHub App manifest code.');
|
||||||
$state = $request->get('state');
|
|
||||||
$github_app = GithubApp::where('uuid', $state)->firstOrFail();
|
$github_app = $this->consumeGithubAppSetupState(
|
||||||
|
request: $request,
|
||||||
|
state: (string) $request->query('state', ''),
|
||||||
|
action: 'manifest',
|
||||||
|
);
|
||||||
|
|
||||||
|
abort_if($this->githubAppHasManifestCredentials($github_app), 403, 'GitHub App credentials are already configured.');
|
||||||
|
|
||||||
$api_url = data_get($github_app, 'api_url');
|
$api_url = data_get($github_app, 'api_url');
|
||||||
$data = Http::withBody(null)->accept('application/vnd.github+json')->post("$api_url/app-manifests/$code/conversions")->throw()->json();
|
$data = Http::withBody(null)
|
||||||
|
->accept('application/vnd.github+json')
|
||||||
|
->timeout(10)
|
||||||
|
->connectTimeout(5)
|
||||||
|
->post("$api_url/app-manifests/$code/conversions")
|
||||||
|
->throw()
|
||||||
|
->json();
|
||||||
|
|
||||||
$id = data_get($data, 'id');
|
$id = data_get($data, 'id');
|
||||||
$slug = data_get($data, 'slug');
|
$slug = data_get($data, 'slug');
|
||||||
$client_id = data_get($data, 'client_id');
|
$client_id = data_get($data, 'client_id');
|
||||||
$client_secret = data_get($data, 'client_secret');
|
$client_secret = data_get($data, 'client_secret');
|
||||||
$private_key = data_get($data, 'pem');
|
$private_key = data_get($data, 'pem');
|
||||||
$webhook_secret = data_get($data, 'webhook_secret');
|
$webhook_secret = data_get($data, 'webhook_secret');
|
||||||
|
|
||||||
|
abort_if(blank($id) || blank($slug) || blank($client_id) || blank($client_secret) || blank($private_key) || blank($webhook_secret), 422, 'GitHub App manifest conversion response is incomplete.');
|
||||||
|
|
||||||
$private_key = PrivateKey::create([
|
$private_key = PrivateKey::create([
|
||||||
'name' => "github-app-{$slug}",
|
'name' => "github-app-{$slug}",
|
||||||
'private_key' => $private_key,
|
'private_key' => $private_key,
|
||||||
|
|
@ -481,26 +537,123 @@ public function redirect(Request $request)
|
||||||
$github_app->save();
|
$github_app->save();
|
||||||
|
|
||||||
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
||||||
} catch (Exception $e) {
|
|
||||||
return handleError($e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function install(Request $request)
|
public function install(Request $request)
|
||||||
{
|
{
|
||||||
try {
|
$setup_action = (string) $request->query('setup_action', '');
|
||||||
$installation_id = $request->get('installation_id');
|
abort_unless(in_array($setup_action, ['install', 'update'], true), 422, 'Invalid GitHub App setup action.');
|
||||||
$source = $request->get('source');
|
|
||||||
$setup_action = $request->get('setup_action');
|
$installation_id = (string) $request->query('installation_id', '');
|
||||||
$github_app = GithubApp::where('uuid', $source)->firstOrFail();
|
abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.');
|
||||||
if ($setup_action === 'install') {
|
|
||||||
$github_app->installation_id = $installation_id;
|
if ($setup_action === 'update') {
|
||||||
$github_app->save();
|
return $this->redirectAfterGithubAppInstallationUpdate($installation_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$github_app = $this->consumeGithubAppSetupState(
|
||||||
|
request: $request,
|
||||||
|
state: (string) $request->query('state', ''),
|
||||||
|
action: 'install',
|
||||||
|
);
|
||||||
|
|
||||||
|
abort_unless(
|
||||||
|
$this->githubInstallationBelongsToApp($github_app, $installation_id),
|
||||||
|
403,
|
||||||
|
'GitHub App installation could not be verified.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$github_app->installation_id = $installation_id;
|
||||||
|
$github_app->save();
|
||||||
|
|
||||||
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
||||||
} catch (Exception $e) {
|
}
|
||||||
return handleError($e);
|
|
||||||
|
private function redirectAfterGithubAppInstallationUpdate(string $installation_id): RedirectResponse
|
||||||
|
{
|
||||||
|
$github_app = GithubApp::ownedByCurrentTeam()
|
||||||
|
->where('installation_id', $installation_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($github_app) {
|
||||||
|
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('source.all');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that the given installation id actually belongs to this GitHub App.
|
||||||
|
*
|
||||||
|
* The installation id arrives as an untrusted query parameter on an
|
||||||
|
* unauthenticated-reachable GET callback, so it must be confirmed against
|
||||||
|
* the GitHub API using the App's own credentials before it is persisted.
|
||||||
|
*/
|
||||||
|
private function githubInstallationBelongsToApp(GithubApp $github_app, string $installation_id): bool
|
||||||
|
{
|
||||||
|
if (blank($github_app->app_id) || blank($github_app->privateKey?->private_key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$jwt = generateGithubJwt($github_app);
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => "Bearer $jwt",
|
||||||
|
'Accept' => 'application/vnd.github+json',
|
||||||
|
])
|
||||||
|
->timeout(10)
|
||||||
|
->connectTimeout(5)
|
||||||
|
->get("{$github_app->api_url}/app/installations/{$installation_id}");
|
||||||
|
|
||||||
|
return $response->successful()
|
||||||
|
&& (string) data_get($response->json(), 'app_id') === (string) $github_app->app_id;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
|
||||||
|
{
|
||||||
|
if (blank($state)) {
|
||||||
|
$this->rejectInvalidGithubAppSetupState($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = Cache::pull($this->githubAppSetupStateCacheKey($state));
|
||||||
|
if (! is_array($payload) || data_get($payload, 'action') !== $action) {
|
||||||
|
$this->rejectInvalidGithubAppSetupState($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$team_id = $request->user()?->currentTeam()?->id;
|
||||||
|
abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403);
|
||||||
|
|
||||||
|
return GithubApp::whereKey(data_get($payload, 'github_app_id'))
|
||||||
|
->where('team_id', data_get($payload, 'team_id'))
|
||||||
|
->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rejectInvalidGithubAppSetupState(Request $request): never
|
||||||
|
{
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpResponseException(
|
||||||
|
redirect()
|
||||||
|
->route('source.all')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function githubAppSetupStateCacheKey(string $state): string
|
||||||
|
{
|
||||||
|
return 'github-app-setup-state:'.hash('sha256', $state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function githubAppHasManifestCredentials(GithubApp $github_app): bool
|
||||||
|
{
|
||||||
|
return filled($github_app->app_id)
|
||||||
|
|| filled($github_app->client_id)
|
||||||
|
|| filled($github_app->client_secret)
|
||||||
|
|| filled($github_app->webhook_secret)
|
||||||
|
|| filled($github_app->private_key_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Actions\Application\CleanupPreviewDeployment;
|
use App\Actions\Application\CleanupPreviewDeployment;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||||
|
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
@ -15,6 +16,7 @@
|
||||||
class Gitlab extends Controller
|
class Gitlab extends Controller
|
||||||
{
|
{
|
||||||
use DetectsSkipDeployCommits;
|
use DetectsSkipDeployCommits;
|
||||||
|
use MatchesManualWebhookApplications;
|
||||||
|
|
||||||
public function manual(Request $request)
|
public function manual(Request $request)
|
||||||
{
|
{
|
||||||
|
|
@ -85,9 +87,18 @@ public function manual(Request $request)
|
||||||
return response($return_payloads);
|
return response($return_payloads);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$applications = Application::where('git_repository', 'like', "%$full_name%");
|
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
||||||
|
if ($full_name === null) {
|
||||||
|
$return_payloads->push([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Nothing to do. Invalid repository.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response($return_payloads);
|
||||||
|
}
|
||||||
|
$applications = Application::query();
|
||||||
if ($x_gitlab_event === 'push') {
|
if ($x_gitlab_event === 'push') {
|
||||||
$applications = $applications->where('git_branch', $branch)->get();
|
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
|
|
@ -98,7 +109,7 @@ public function manual(Request $request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($x_gitlab_event === 'merge_request') {
|
if ($x_gitlab_event === 'merge_request') {
|
||||||
$applications = $applications->where('git_branch', $base_branch)->get();
|
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
|
|
@ -117,11 +128,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'event' => $x_gitlab_event,
|
'event' => $x_gitlab_event,
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Webhook secret not configured.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -132,11 +139,7 @@ public function manual(Request $request)
|
||||||
'repository' => $full_name ?? null,
|
'repository' => $full_name ?? null,
|
||||||
'event' => $x_gitlab_event,
|
'event' => $x_gitlab_event,
|
||||||
]);
|
]);
|
||||||
$return_payloads->push([
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'Invalid signature.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,10 @@
|
||||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||||
use App\Http\Middleware\EncryptCookies;
|
use App\Http\Middleware\EncryptCookies;
|
||||||
use App\Http\Middleware\EnsureMcpEnabled;
|
use App\Http\Middleware\EnsureMcpEnabled;
|
||||||
|
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
|
||||||
use App\Http\Middleware\PreventRequestsDuringMaintenance;
|
use App\Http\Middleware\PreventRequestsDuringMaintenance;
|
||||||
use App\Http\Middleware\RedirectIfAuthenticated;
|
use App\Http\Middleware\RedirectIfAuthenticated;
|
||||||
|
use App\Http\Middleware\RejectMapledeployRevokedUser;
|
||||||
use App\Http\Middleware\TrimStrings;
|
use App\Http\Middleware\TrimStrings;
|
||||||
use App\Http\Middleware\TrustHosts;
|
use App\Http\Middleware\TrustHosts;
|
||||||
use App\Http\Middleware\TrustProxies;
|
use App\Http\Middleware\TrustProxies;
|
||||||
|
|
@ -70,6 +72,7 @@ class Kernel extends HttpKernel
|
||||||
ShareErrorsFromSession::class,
|
ShareErrorsFromSession::class,
|
||||||
VerifyCsrfToken::class,
|
VerifyCsrfToken::class,
|
||||||
SubstituteBindings::class,
|
SubstituteBindings::class,
|
||||||
|
RejectMapledeployRevokedUser::class,
|
||||||
CheckForcePasswordReset::class,
|
CheckForcePasswordReset::class,
|
||||||
DecideWhatToDoWithUser::class,
|
DecideWhatToDoWithUser::class,
|
||||||
|
|
||||||
|
|
@ -104,6 +107,7 @@ class Kernel extends HttpKernel
|
||||||
'ability' => CheckForAnyAbility::class,
|
'ability' => CheckForAnyAbility::class,
|
||||||
'api.ability' => ApiAbility::class,
|
'api.ability' => ApiAbility::class,
|
||||||
'api.sensitive' => ApiSensitiveData::class,
|
'api.sensitive' => ApiSensitiveData::class,
|
||||||
|
'api.token.team' => EnsureTokenBelongsToCurrentTeamMember::class,
|
||||||
'can.create.resources' => CanCreateResources::class,
|
'can.create.resources' => CanCreateResources::class,
|
||||||
'can.update.resource' => CanUpdateResource::class,
|
'can.update.resource' => CanUpdateResource::class,
|
||||||
'can.access.terminal' => CanAccessTerminal::class,
|
'can.access.terminal' => CanAccessTerminal::class,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,16 @@ public function handle(Request $request, Closure $next): Response
|
||||||
$currentTeam = auth()->user()?->recreate_personal_team();
|
$currentTeam = auth()->user()?->recreate_personal_team();
|
||||||
refreshSession($currentTeam);
|
refreshSession($currentTeam);
|
||||||
}
|
}
|
||||||
|
$preferredTeam = auth()?->user()?->mapledeployPreferredTeam();
|
||||||
|
if (
|
||||||
|
$preferredTeam &&
|
||||||
|
$preferredTeam->id === 0 &&
|
||||||
|
auth()?->user()?->currentTeam()?->id !== 0
|
||||||
|
) {
|
||||||
|
// MapleDeploy branding: repair sessions that landed in the empty
|
||||||
|
// personal team before dashboard-managed root-team access existed.
|
||||||
|
refreshSession($preferredTeam);
|
||||||
|
}
|
||||||
if (auth()?->user()?->currentTeam()) {
|
if (auth()?->user()?->currentTeam()) {
|
||||||
refreshSession(auth()->user()->currentTeam());
|
refreshSession(auth()->user()->currentTeam());
|
||||||
} elseif (auth()?->user()?->teams?->count() > 0) {
|
} elseif (auth()?->user()?->teams?->count() > 0) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureTokenBelongsToCurrentTeamMember
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$token = $user?->currentAccessToken();
|
||||||
|
$teamId = $token?->team_id;
|
||||||
|
|
||||||
|
if (! $user || ! $token || is_null($teamId)) {
|
||||||
|
return response()->json(['message' => 'Invalid token.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$team = $user->teams()
|
||||||
|
->where('teams.id', $teamId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $team) {
|
||||||
|
return response()->json(['message' => 'Invalid token.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$role = $team->pivot?->role;
|
||||||
|
if (($token->can('root') || $token->can('write') || $token->can('write:sensitive'))
|
||||||
|
&& ! in_array($role, ['admin', 'owner'], true)) {
|
||||||
|
return response()->json(['message' => 'Missing required team role.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Http/Middleware/RejectMapledeployRevokedUser.php
Normal file
37
app/Http/Middleware/RejectMapledeployRevokedUser.php
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RejectMapledeployRevokedUser
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user?->isMapledeployRevoked()) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapleDeploy branding: revocation is marked on the user row so old
|
||||||
|
// browser sessions are rejected even when SESSION_DRIVER is not database.
|
||||||
|
auth()->logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
if ($request->routeIs('login') || $request->path() === 'login') {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('login')->withErrors([
|
||||||
|
'email' => __('auth.failed'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -197,7 +197,7 @@ public function tags()
|
||||||
|
|
||||||
public function __construct(public int $application_deployment_queue_id)
|
public function __construct(public int $application_deployment_queue_id)
|
||||||
{
|
{
|
||||||
$this->onQueue('high');
|
$this->onQueue(deployment_queue());
|
||||||
|
|
||||||
$this->application_deployment_queue = ApplicationDeploymentQueue::find($this->application_deployment_queue_id);
|
$this->application_deployment_queue = ApplicationDeploymentQueue::find($this->application_deployment_queue_id);
|
||||||
$this->nixpacks_plan_json = collect([]);
|
$this->nixpacks_plan_json = collect([]);
|
||||||
|
|
@ -220,6 +220,7 @@ public function __construct(public int $application_deployment_queue_id)
|
||||||
$this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile';
|
$this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile';
|
||||||
$this->only_this_server = $this->application_deployment_queue->only_this_server;
|
$this->only_this_server = $this->application_deployment_queue->only_this_server;
|
||||||
$this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag;
|
$this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag;
|
||||||
|
$this->validateDockerRegistryImageConfiguration();
|
||||||
|
|
||||||
$this->git_type = data_get($this->application_deployment_queue, 'git_type');
|
$this->git_type = data_get($this->application_deployment_queue, 'git_type');
|
||||||
|
|
||||||
|
|
@ -1106,7 +1107,7 @@ private function push_to_docker_registry()
|
||||||
'hidden' => true,
|
'hidden' => true,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
if ($this->application->docker_registry_image_tag) {
|
if ($this->shouldPushDockerRegistryImageTag()) {
|
||||||
// Tag image with docker_registry_image_tag
|
// Tag image with docker_registry_image_tag
|
||||||
$this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag.");
|
$this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag.");
|
||||||
$this->execute_remote_command(
|
$this->execute_remote_command(
|
||||||
|
|
@ -1130,6 +1131,30 @@ private function push_to_docker_registry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldPushDockerRegistryImageTag(): bool
|
||||||
|
{
|
||||||
|
if (blank($this->application->docker_registry_image_tag)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pull_request_id === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateDockerRegistryImageConfiguration(): void
|
||||||
|
{
|
||||||
|
if (! ValidationPatterns::isValidDockerImageName($this->application->docker_registry_image_name)) {
|
||||||
|
throw new DeploymentException('Docker registry image name contains invalid characters.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! ValidationPatterns::isValidDockerImageTag($this->application->docker_registry_image_tag)) {
|
||||||
|
throw new DeploymentException('Docker registry image tag contains invalid characters.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! ValidationPatterns::isValidDockerImageTag($this->dockerImagePreviewTag)) {
|
||||||
|
throw new DeploymentException('Docker registry preview image tag contains invalid characters.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function generate_image_names()
|
private function generate_image_names()
|
||||||
{
|
{
|
||||||
if ($this->application->dockerfile) {
|
if ($this->application->dockerfile) {
|
||||||
|
|
@ -1293,12 +1318,8 @@ private function generate_runtime_environment_variables()
|
||||||
$sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id');
|
$sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id');
|
||||||
}
|
}
|
||||||
if ($this->build_pack === 'dockercompose') {
|
if ($this->build_pack === 'dockercompose') {
|
||||||
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
|
$sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
||||||
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
|
$sorted_environment_variables_preview = $sorted_environment_variables_preview->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
||||||
});
|
|
||||||
$sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
|
|
||||||
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
$ports = $this->application->main_port();
|
$ports = $this->application->main_port();
|
||||||
$coolify_envs = $this->generate_coolify_env_variables();
|
$coolify_envs = $this->generate_coolify_env_variables();
|
||||||
|
|
@ -1367,7 +1388,7 @@ private function generate_runtime_environment_variables()
|
||||||
|
|
||||||
// Add PORT if not exists, use the first port as default
|
// Add PORT if not exists, use the first port as default
|
||||||
if ($this->build_pack !== 'dockercompose') {
|
if ($this->build_pack !== 'dockercompose') {
|
||||||
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) {
|
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty() && ! empty($ports)) {
|
||||||
$envs->push("PORT={$ports[0]}");
|
$envs->push("PORT={$ports[0]}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1451,6 +1472,15 @@ private function generate_runtime_environment_variables()
|
||||||
return $envs;
|
return $envs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool
|
||||||
|
{
|
||||||
|
$key = str($environmentVariable->key);
|
||||||
|
|
||||||
|
return $key->startsWith('SERVICE_FQDN_')
|
||||||
|
|| $key->startsWith('SERVICE_URL_')
|
||||||
|
|| $key->startsWith('SERVICE_NAME_');
|
||||||
|
}
|
||||||
|
|
||||||
private function save_runtime_environment_variables()
|
private function save_runtime_environment_variables()
|
||||||
{
|
{
|
||||||
// This method saves the .env file with ALL runtime variables
|
// This method saves the .env file with ALL runtime variables
|
||||||
|
|
@ -1666,11 +1696,9 @@ private function generate_buildtime_environment_variables()
|
||||||
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these
|
// For Docker Compose, filter out generated SERVICE_* variables as we generate these
|
||||||
if ($this->build_pack === 'dockercompose') {
|
if ($this->build_pack === 'dockercompose') {
|
||||||
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
|
$sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
||||||
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($sorted_environment_variables as $env) {
|
foreach ($sorted_environment_variables as $env) {
|
||||||
|
|
@ -1719,11 +1747,9 @@ private function generate_buildtime_environment_variables()
|
||||||
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values
|
// For Docker Compose, filter out generated SERVICE_* variables as we generate these with PR-specific values
|
||||||
if ($this->build_pack === 'dockercompose') {
|
if ($this->build_pack === 'dockercompose') {
|
||||||
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
|
$sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
||||||
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($sorted_environment_variables as $env) {
|
foreach ($sorted_environment_variables as $env) {
|
||||||
|
|
@ -2103,21 +2129,23 @@ private function prepare_builder_image(bool $firstTry = true)
|
||||||
$helperImage = "{$helperImage}:".getHelperVersion();
|
$helperImage = "{$helperImage}:".getHelperVersion();
|
||||||
// Get user home directory
|
// Get user home directory
|
||||||
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
|
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
|
||||||
|
instant_remote_process(["mkdir -p {$this->serverUserHomeDir}/.docker/buildx"], $this->server);
|
||||||
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
|
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
|
||||||
|
|
||||||
$env_flags = $this->generate_docker_env_flags_for_secrets();
|
$env_flags = $this->generate_docker_env_flags_for_secrets();
|
||||||
|
$buildxMetadataVolume = "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx";
|
||||||
if ($this->use_build_server) {
|
if ($this->use_build_server) {
|
||||||
if ($this->dockerConfigFileExists === 'NOK') {
|
if ($this->dockerConfigFileExists === 'NOK') {
|
||||||
throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
|
throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
|
||||||
}
|
}
|
||||||
$runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
$runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
||||||
} else {
|
} else {
|
||||||
if ($this->dockerConfigFileExists === 'OK') {
|
if ($this->dockerConfigFileExists === 'OK') {
|
||||||
$safeNetwork = escapeshellarg($this->destination->network);
|
$safeNetwork = escapeshellarg($this->destination->network);
|
||||||
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
||||||
} else {
|
} else {
|
||||||
$safeNetwork = escapeshellarg($this->destination->network);
|
$safeNetwork = escapeshellarg($this->destination->network);
|
||||||
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($firstTry) {
|
if ($firstTry) {
|
||||||
|
|
@ -2222,11 +2250,22 @@ private function set_coolify_variables()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isset($this->application->git_branch)) {
|
if (isset($this->application->git_branch)) {
|
||||||
$this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} ";
|
$this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' ';
|
||||||
}
|
}
|
||||||
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
|
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
|
||||||
|
{
|
||||||
|
$sshCommand = "ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null";
|
||||||
|
|
||||||
|
if ($identityFile !== null) {
|
||||||
|
$sshCommand .= " -i {$identityFile} -o IdentitiesOnly=yes";
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'GIT_SSH_COMMAND="'.$sshCommand.'" git ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef);
|
||||||
|
}
|
||||||
|
|
||||||
private function check_git_if_build_needed()
|
private function check_git_if_build_needed()
|
||||||
{
|
{
|
||||||
if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) {
|
if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) {
|
||||||
|
|
@ -2261,18 +2300,19 @@ private function check_git_if_build_needed()
|
||||||
$private_key = data_get($this->application, 'private_key.private_key');
|
$private_key = data_get($this->application, 'private_key.private_key');
|
||||||
if ($private_key) {
|
if ($private_key) {
|
||||||
$private_key = base64_encode($private_key);
|
$private_key = base64_encode($private_key);
|
||||||
|
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$this->deployment_uuid}";
|
||||||
$this->execute_remote_command(
|
$this->execute_remote_command(
|
||||||
[
|
[
|
||||||
executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'),
|
executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee /root/.ssh/id_rsa > /dev/null"),
|
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
|
executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"),
|
executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, $customSshKeyLocation)),
|
||||||
'hidden' => true,
|
'hidden' => true,
|
||||||
'save' => 'git_commit_sha',
|
'save' => 'git_commit_sha',
|
||||||
]
|
]
|
||||||
|
|
@ -2280,7 +2320,7 @@ private function check_git_if_build_needed()
|
||||||
} else {
|
} else {
|
||||||
$this->execute_remote_command(
|
$this->execute_remote_command(
|
||||||
[
|
[
|
||||||
executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"),
|
executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef)),
|
||||||
'hidden' => true,
|
'hidden' => true,
|
||||||
'save' => 'git_commit_sha',
|
'save' => 'git_commit_sha',
|
||||||
],
|
],
|
||||||
|
|
@ -3019,6 +3059,10 @@ private function generate_env_variables()
|
||||||
->where('is_buildtime', true)
|
->where('is_buildtime', true)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
if ($this->build_pack === 'dockercompose') {
|
||||||
|
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($envs as $env) {
|
foreach ($envs as $env) {
|
||||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||||
if (! is_null($resolvedValue)) {
|
if (! is_null($resolvedValue)) {
|
||||||
|
|
@ -3031,6 +3075,10 @@ private function generate_env_variables()
|
||||||
->where('is_buildtime', true)
|
->where('is_buildtime', true)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
if ($this->build_pack === 'dockercompose') {
|
||||||
|
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($envs as $env) {
|
foreach ($envs as $env) {
|
||||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||||
if (! is_null($resolvedValue)) {
|
if (! is_null($resolvedValue)) {
|
||||||
|
|
@ -3091,7 +3139,7 @@ private function generate_compose_file()
|
||||||
'image' => $this->production_image_name,
|
'image' => $this->production_image_name,
|
||||||
'container_name' => $this->container_name,
|
'container_name' => $this->container_name,
|
||||||
'restart' => RESTART_MODE,
|
'restart' => RESTART_MODE,
|
||||||
'expose' => $ports,
|
...(! empty($ports) ? ['expose' => $ports] : []),
|
||||||
'networks' => [
|
'networks' => [
|
||||||
$this->destination->network => [
|
$this->destination->network => [
|
||||||
'aliases' => array_merge(
|
'aliases' => array_merge(
|
||||||
|
|
@ -3123,10 +3171,12 @@ private function generate_compose_file()
|
||||||
// If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used
|
// If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used
|
||||||
// If healthcheck is disabled, no healthcheck will be added
|
// If healthcheck is disabled, no healthcheck will be added
|
||||||
if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) {
|
if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) {
|
||||||
|
$healthcheck_command = $this->generate_healthcheck_commands();
|
||||||
|
if ($healthcheck_command !== null) {
|
||||||
$docker_compose['services'][$this->container_name]['healthcheck'] = [
|
$docker_compose['services'][$this->container_name]['healthcheck'] = [
|
||||||
'test' => [
|
'test' => [
|
||||||
'CMD-SHELL',
|
'CMD-SHELL',
|
||||||
$this->generate_healthcheck_commands(),
|
$healthcheck_command,
|
||||||
],
|
],
|
||||||
'interval' => $this->application->health_check_interval.'s',
|
'interval' => $this->application->health_check_interval.'s',
|
||||||
'timeout' => $this->application->health_check_timeout.'s',
|
'timeout' => $this->application->health_check_timeout.'s',
|
||||||
|
|
@ -3134,6 +3184,7 @@ private function generate_compose_file()
|
||||||
'start_period' => $this->application->health_check_start_period.'s',
|
'start_period' => $this->application->health_check_start_period.'s',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (! is_null($this->application->limits_cpuset)) {
|
if (! is_null($this->application->limits_cpuset)) {
|
||||||
data_set($docker_compose, 'services.'.$this->container_name.'.cpuset', $this->application->limits_cpuset);
|
data_set($docker_compose, 'services.'.$this->container_name.'.cpuset', $this->application->limits_cpuset);
|
||||||
|
|
@ -3342,7 +3393,11 @@ private function generate_healthcheck_commands()
|
||||||
|
|
||||||
// HTTP type healthcheck (default)
|
// HTTP type healthcheck (default)
|
||||||
if (! $this->application->health_check_port) {
|
if (! $this->application->health_check_port) {
|
||||||
|
if (! empty($this->application->ports_exposes_array)) {
|
||||||
$health_check_port = (int) $this->application->ports_exposes_array[0];
|
$health_check_port = (int) $this->application->ports_exposes_array[0];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$health_check_port = (int) $this->application->health_check_port;
|
$health_check_port = (int) $this->application->health_check_port;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Process;
|
use Illuminate\Support\Facades\Process;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
|
@ -20,6 +21,132 @@ public function handle()
|
||||||
{
|
{
|
||||||
$this->cleanupStaleConnections();
|
$this->cleanupStaleConnections();
|
||||||
$this->cleanupNonExistentServerConnections();
|
$this->cleanupNonExistentServerConnections();
|
||||||
|
$this->cleanupOrphanedSshProcesses();
|
||||||
|
$this->cleanupOrphanedCloudflaredProcesses();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill backgrounded ssh master processes that lost the ControlPath socket
|
||||||
|
* race. Such processes are not masters, so ControlPersist never reaps them
|
||||||
|
* and they leak memory until the container restarts. A legitimate master
|
||||||
|
* always owns its socket file; an orphan has none.
|
||||||
|
*
|
||||||
|
* Processes younger than the minimum age are skipped: a freshly forked
|
||||||
|
* master creates its socket a few milliseconds after starting, so a young
|
||||||
|
* process with no socket may simply be mid-establish rather than orphaned.
|
||||||
|
*/
|
||||||
|
private function cleanupOrphanedSshProcesses(): void
|
||||||
|
{
|
||||||
|
$muxDir = storage_path('app/ssh/mux');
|
||||||
|
$minAge = (int) config('constants.ssh.mux_orphan_min_age');
|
||||||
|
|
||||||
|
foreach ($this->listProcesses() as $process) {
|
||||||
|
// Backgrounded ssh master: current `ssh -fN` or legacy `ssh -fNM`.
|
||||||
|
if (! preg_match('#(^|/)ssh -fN#', $process['args'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only ever touch ssh processes pointing at Coolify's mux directory.
|
||||||
|
if (! preg_match('#ControlPath=('.preg_quote($muxDir, '#').'/\S+)#', $process['args'], $pathMatch)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($process['etimes'] >= $minAge && ! file_exists($pathMatch[1])) {
|
||||||
|
$this->reapOrphan('ssh', $process);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill orphaned `cloudflared access ssh` proxy processes. Each is spawned
|
||||||
|
* as the SSH ProxyCommand transport for a Cloudflare Tunnel server and must
|
||||||
|
* die with its parent ssh. When that ssh is killed or orphaned (e.g. a lost
|
||||||
|
* mux master), the cloudflared process can leak and accumulate. A legitimate
|
||||||
|
* proxy always has a live ssh parent; one without is safe to reap.
|
||||||
|
*
|
||||||
|
* Processes younger than the minimum age are skipped so a proxy whose parent
|
||||||
|
* ssh is still starting up, or a transient `ssh -O check` proxy mid-exit, is
|
||||||
|
* never mistaken for an orphan.
|
||||||
|
*/
|
||||||
|
private function cleanupOrphanedCloudflaredProcesses(): void
|
||||||
|
{
|
||||||
|
$minAge = (int) config('constants.ssh.mux_orphan_min_age');
|
||||||
|
$processes = $this->listProcesses();
|
||||||
|
|
||||||
|
$sshPids = [];
|
||||||
|
foreach ($processes as $process) {
|
||||||
|
// The ssh binary itself, not `cloudflared access ssh` (space before ssh).
|
||||||
|
if (preg_match('#(^|/)ssh\s#', $process['args'])) {
|
||||||
|
$sshPids[$process['pid']] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($processes as $process) {
|
||||||
|
// `cloudflared access ssh`, never the `cloudflared tunnel` daemon.
|
||||||
|
if (! str_contains($process['args'], 'cloudflared access ssh')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orphaned when no live ssh process is its parent.
|
||||||
|
if ($process['etimes'] >= $minAge && ! isset($sshPids[$process['ppid']])) {
|
||||||
|
$this->reapOrphan('cloudflared', $process);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reap a detected orphan process. When orphan reaping is disabled (the
|
||||||
|
* default), the orphan is only logged — a dry-run mode that lets operators
|
||||||
|
* verify what would be killed before enabling it for real.
|
||||||
|
*
|
||||||
|
* @param array{pid: string, ppid: string, etimes: int, args: string} $process
|
||||||
|
*/
|
||||||
|
private function reapOrphan(string $kind, array $process): void
|
||||||
|
{
|
||||||
|
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
|
||||||
|
Log::info("Orphaned {$kind} process detected (dry-run, not killed)", [
|
||||||
|
'pid' => $process['pid'],
|
||||||
|
'etimes' => $process['etimes'],
|
||||||
|
'command' => $process['args'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process::run('kill '.escapeshellarg($process['pid']));
|
||||||
|
Log::info("Killed orphaned {$kind} process", [
|
||||||
|
'pid' => $process['pid'],
|
||||||
|
'etimes' => $process['etimes'],
|
||||||
|
'command' => $process['args'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of running processes.
|
||||||
|
*
|
||||||
|
* @return list<array{pid: string, ppid: string, etimes: int, args: string}>
|
||||||
|
*/
|
||||||
|
private function listProcesses(): array
|
||||||
|
{
|
||||||
|
$ps = Process::run('ps -ww -eo pid=,ppid=,etimes=,args=');
|
||||||
|
if ($ps->exitCode() !== 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$processes = [];
|
||||||
|
foreach (explode("\n", trim($ps->output())) as $line) {
|
||||||
|
if (! preg_match('/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/', $line, $matches)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$processes[] = [
|
||||||
|
'pid' => $matches[1],
|
||||||
|
'ppid' => $matches[2],
|
||||||
|
'etimes' => (int) $matches[3],
|
||||||
|
'args' => $matches[4],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $processes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function cleanupStaleConnections()
|
private function cleanupStaleConnections()
|
||||||
|
|
@ -31,7 +158,7 @@ private function cleanupStaleConnections()
|
||||||
$server = Server::where('uuid', $serverUuid)->first();
|
$server = Server::where('uuid', $serverUuid)->first();
|
||||||
|
|
||||||
if (! $server) {
|
if (! $server) {
|
||||||
$this->removeMultiplexFile($muxFile);
|
$this->removeMultiplexFile($muxFile, 'server_not_found');
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -41,14 +168,14 @@ private function cleanupStaleConnections()
|
||||||
$checkProcess = Process::run($checkCommand);
|
$checkProcess = Process::run($checkCommand);
|
||||||
|
|
||||||
if ($checkProcess->exitCode() !== 0) {
|
if ($checkProcess->exitCode() !== 0) {
|
||||||
$this->removeMultiplexFile($muxFile);
|
$this->removeMultiplexFile($muxFile, 'connection_check_failed');
|
||||||
} else {
|
} else {
|
||||||
$muxContent = Storage::disk('ssh-mux')->get($muxFile);
|
$muxContent = Storage::disk('ssh-mux')->get($muxFile);
|
||||||
$establishedAt = Carbon::parse(substr($muxContent, 37));
|
$establishedAt = Carbon::parse(substr($muxContent, 37));
|
||||||
$expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time'));
|
$expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time'));
|
||||||
|
|
||||||
if (Carbon::now()->isAfter($expirationTime)) {
|
if (Carbon::now()->isAfter($expirationTime)) {
|
||||||
$this->removeMultiplexFile($muxFile);
|
$this->removeMultiplexFile($muxFile, 'expired');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +189,7 @@ private function cleanupNonExistentServerConnections()
|
||||||
foreach ($muxFiles as $muxFile) {
|
foreach ($muxFiles as $muxFile) {
|
||||||
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
|
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
|
||||||
if (! in_array($serverUuid, $existingServerUuids)) {
|
if (! in_array($serverUuid, $existingServerUuids)) {
|
||||||
$this->removeMultiplexFile($muxFile);
|
$this->removeMultiplexFile($muxFile, 'server_does_not_exist');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -72,11 +199,30 @@ private function extractServerUuidFromMuxFile($muxFile)
|
||||||
return substr($muxFile, 4);
|
return substr($muxFile, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function removeMultiplexFile($muxFile)
|
/**
|
||||||
|
* Close and delete a stale mux socket file. When orphan reaping is disabled
|
||||||
|
* (the default), the file is only logged — a dry-run mode that lets operators
|
||||||
|
* verify what would be removed before enabling it for real.
|
||||||
|
*/
|
||||||
|
private function removeMultiplexFile(string $muxFile, string $reason): void
|
||||||
{
|
{
|
||||||
|
if (! config('constants.ssh.mux_orphan_reap_enabled')) {
|
||||||
|
Log::info('Stale mux file detected (dry-run, not removed)', [
|
||||||
|
'file' => $muxFile,
|
||||||
|
'reason' => $reason,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
|
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
|
||||||
$closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null";
|
$closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null";
|
||||||
Process::run($closeCommand);
|
Process::run($closeCommand);
|
||||||
Storage::disk('ssh-mux')->delete($muxFile);
|
Storage::disk('ssh-mux')->delete($muxFile);
|
||||||
|
|
||||||
|
Log::info('Removed stale mux file', [
|
||||||
|
'file' => $muxFile,
|
||||||
|
'reason' => $reason,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
|
|
||||||
public function __construct(public ScheduledDatabaseBackup $backup)
|
public function __construct(public ScheduledDatabaseBackup $backup)
|
||||||
{
|
{
|
||||||
$this->onQueue('high');
|
$this->onQueue(crons_queue());
|
||||||
$this->timeout = $backup->timeout ?? 3600;
|
$this->timeout = $backup->timeout ?? 3600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -668,12 +668,14 @@ private function calculate_size()
|
||||||
private function upload_to_s3(): void
|
private function upload_to_s3(): void
|
||||||
{
|
{
|
||||||
if (is_null($this->s3)) {
|
if (is_null($this->s3)) {
|
||||||
|
$previousS3StorageId = $this->backup->s3_storage_id;
|
||||||
|
|
||||||
$this->backup->update([
|
$this->backup->update([
|
||||||
'save_s3' => false,
|
'save_s3' => false,
|
||||||
's3_storage_id' => null,
|
's3_storage_id' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.');
|
throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($previousS3StorageId ?? 'null').'). S3 backup has been disabled for this schedule.');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ public function __construct(
|
||||||
public string $commitSha,
|
public string $commitSha,
|
||||||
public ?string $authorAssociation,
|
public ?string $authorAssociation,
|
||||||
public string $fullName,
|
public string $fullName,
|
||||||
|
public bool $isForkPullRequest = false,
|
||||||
) {
|
) {
|
||||||
$this->onQueue('high');
|
$this->onQueue('high');
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +93,17 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp
|
||||||
|
|
||||||
// Check if PR deployments from public contributors are restricted
|
// Check if PR deployments from public contributors are restricted
|
||||||
if (! $application->settings->is_pr_deployments_public_enabled) {
|
if (! $application->settings->is_pr_deployments_public_enabled) {
|
||||||
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
// Fork PRs carry untrusted code from a repository outside our control.
|
||||||
|
// GitHub's author_association cannot be trusted to gate these (it grants
|
||||||
|
// CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
|
||||||
|
// PRs are never deployed automatically when public previews are off.
|
||||||
|
if ($this->isForkPullRequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-repo (non-fork) branch PRs require push access to the base repo,
|
||||||
|
// so only trusted associations are allowed to trigger a deployment.
|
||||||
|
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
|
||||||
if (! in_array($this->authorAssociation, $trustedAssociations)) {
|
if (! in_array($this->authorAssociation, $trustedAssociations)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,16 @@
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\ServiceApplication;
|
use App\Models\ServiceApplication;
|
||||||
use App\Models\ServiceDatabase;
|
use App\Models\ServiceDatabase;
|
||||||
|
use App\Models\StandaloneClickhouse;
|
||||||
|
use App\Models\StandaloneDocker;
|
||||||
|
use App\Models\StandaloneDragonfly;
|
||||||
|
use App\Models\StandaloneKeydb;
|
||||||
|
use App\Models\StandaloneMariadb;
|
||||||
|
use App\Models\StandaloneMongodb;
|
||||||
|
use App\Models\StandaloneMysql;
|
||||||
|
use App\Models\StandalonePostgresql;
|
||||||
|
use App\Models\StandaloneRedis;
|
||||||
|
use App\Models\SwarmDocker;
|
||||||
use App\Notifications\Container\ContainerRestarted;
|
use App\Notifications\Container\ContainerRestarted;
|
||||||
use App\Services\ContainerStatusAggregator;
|
use App\Services\ContainerStatusAggregator;
|
||||||
use App\Traits\CalculatesExcludedStatus;
|
use App\Traits\CalculatesExcludedStatus;
|
||||||
|
|
@ -25,6 +35,7 @@
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Laravel\Horizon\Contracts\Silenced;
|
use Laravel\Horizon\Contracts\Silenced;
|
||||||
|
|
||||||
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||||
|
|
@ -46,6 +57,18 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||||
|
|
||||||
public Collection $services;
|
public Collection $services;
|
||||||
|
|
||||||
|
public Collection $applicationsById;
|
||||||
|
|
||||||
|
public Collection $previewsByKey;
|
||||||
|
|
||||||
|
public Collection $databasesByUuid;
|
||||||
|
|
||||||
|
public Collection $servicesById;
|
||||||
|
|
||||||
|
public Collection $serviceApplicationsById;
|
||||||
|
|
||||||
|
public Collection $serviceDatabasesById;
|
||||||
|
|
||||||
public Collection $allApplicationIds;
|
public Collection $allApplicationIds;
|
||||||
|
|
||||||
public Collection $allDatabaseUuids;
|
public Collection $allDatabaseUuids;
|
||||||
|
|
@ -78,6 +101,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
||||||
|
|
||||||
public bool $foundLogDrainContainer = false;
|
public bool $foundLogDrainContainer = false;
|
||||||
|
|
||||||
|
private ?array $cachedDestinationIds = null;
|
||||||
|
|
||||||
public function middleware(): array
|
public function middleware(): array
|
||||||
{
|
{
|
||||||
return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->expireAfter(30)->dontRelease()];
|
return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->expireAfter(30)->dontRelease()];
|
||||||
|
|
@ -103,6 +128,12 @@ public function __construct(public Server $server, public $data)
|
||||||
$this->allTcpProxyUuids = collect();
|
$this->allTcpProxyUuids = collect();
|
||||||
$this->allServiceApplicationIds = collect();
|
$this->allServiceApplicationIds = collect();
|
||||||
$this->allServiceDatabaseIds = collect();
|
$this->allServiceDatabaseIds = collect();
|
||||||
|
$this->applicationsById = collect();
|
||||||
|
$this->previewsByKey = collect();
|
||||||
|
$this->databasesByUuid = collect();
|
||||||
|
$this->servicesById = collect();
|
||||||
|
$this->serviceApplicationsById = collect();
|
||||||
|
$this->serviceDatabasesById = collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle()
|
public function handle()
|
||||||
|
|
@ -120,6 +151,16 @@ public function handle()
|
||||||
$this->allTcpProxyUuids ??= collect();
|
$this->allTcpProxyUuids ??= collect();
|
||||||
$this->allServiceApplicationIds ??= collect();
|
$this->allServiceApplicationIds ??= collect();
|
||||||
$this->allServiceDatabaseIds ??= collect();
|
$this->allServiceDatabaseIds ??= collect();
|
||||||
|
$this->applicationsById ??= collect();
|
||||||
|
$this->previewsByKey ??= collect();
|
||||||
|
$this->databasesByUuid ??= collect();
|
||||||
|
$this->servicesById ??= collect();
|
||||||
|
$this->serviceApplicationsById ??= collect();
|
||||||
|
$this->serviceDatabasesById ??= collect();
|
||||||
|
|
||||||
|
// Eager-load relations the job touches repeatedly to avoid lazy-load queries
|
||||||
|
// (settings: disk threshold, isProxyShouldRun, isLogDrainEnabled; team: notifications).
|
||||||
|
$this->server->loadMissing(['settings', 'team']);
|
||||||
|
|
||||||
// TODO: Swarm is not supported yet
|
// TODO: Swarm is not supported yet
|
||||||
if (! $this->data) {
|
if (! $this->data) {
|
||||||
|
|
@ -127,30 +168,40 @@ public function handle()
|
||||||
}
|
}
|
||||||
$data = collect($this->data);
|
$data = collect($this->data);
|
||||||
|
|
||||||
$this->server->sentinelHeartbeat();
|
// Heartbeat is updated by SentinelController on every push, before dispatch.
|
||||||
|
|
||||||
$this->containers = collect(data_get($data, 'containers'));
|
$this->containers = collect(data_get($data, 'containers'));
|
||||||
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
|
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
|
||||||
|
|
||||||
// Only dispatch storage check when disk percentage actually changes
|
// Only dispatch the storage check when disk usage is at/above the notification
|
||||||
|
// threshold AND the value changed. Below the threshold ServerStorageCheckJob
|
||||||
|
// has nothing to do (it only sends a HighDiskUsage notification), so dispatching
|
||||||
|
// it is wasted work — and most servers sit well below the threshold.
|
||||||
|
$diskThreshold = data_get($this->server, 'settings.server_disk_usage_notification_threshold', 80);
|
||||||
$storageCacheKey = 'storage-check:'.$this->server->id;
|
$storageCacheKey = 'storage-check:'.$this->server->id;
|
||||||
$lastPercentage = Cache::get($storageCacheKey);
|
$lastPercentage = Cache::get($storageCacheKey);
|
||||||
if ($lastPercentage === null || (string) $lastPercentage !== (string) $filesystemUsageRoot) {
|
if ($filesystemUsageRoot !== null
|
||||||
|
&& $filesystemUsageRoot >= $diskThreshold
|
||||||
|
&& (string) $lastPercentage !== (string) $filesystemUsageRoot) {
|
||||||
Cache::put($storageCacheKey, $filesystemUsageRoot, 600);
|
Cache::put($storageCacheKey, $filesystemUsageRoot, 600);
|
||||||
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
|
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
|
||||||
|
} elseif ($filesystemUsageRoot !== null && $filesystemUsageRoot < $diskThreshold) {
|
||||||
|
Cache::forget($storageCacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->containers->isEmpty()) {
|
if ($this->containers->isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->applications = $this->server->applications();
|
$this->applications = $this->loadApplications();
|
||||||
$this->databases = $this->server->databases();
|
$this->databases = $this->loadDatabases();
|
||||||
$this->previews = $this->server->previews();
|
$this->previews = $this->loadPreviews();
|
||||||
// Eager load service applications and databases to avoid N+1 queries
|
$this->services = $this->loadServices();
|
||||||
$this->services = $this->server->services()
|
$this->applicationsById = $this->applications->keyBy(fn ($application) => (string) $application->id);
|
||||||
->with(['applications:id,service_id', 'databases:id,service_id'])
|
$this->previewsByKey = $this->previews->keyBy(fn ($preview) => $preview->application_id.':'.$preview->pull_request_id);
|
||||||
->get();
|
$this->databasesByUuid = $this->databases->keyBy('uuid');
|
||||||
|
$this->servicesById = $this->services->keyBy(fn ($service) => (string) $service->id);
|
||||||
|
$this->serviceApplicationsById = $this->services->flatMap(fn ($service) => $service->applications)->keyBy(fn ($application) => (string) $application->id);
|
||||||
|
$this->serviceDatabasesById = $this->services->flatMap(fn ($service) => $service->databases)->keyBy(fn ($database) => (string) $database->id);
|
||||||
|
|
||||||
$this->allApplicationIds = $this->applications->filter(function ($application) {
|
$this->allApplicationIds = $this->applications->filter(function ($application) {
|
||||||
return $application->additional_servers_count === 0;
|
return $application->additional_servers_count === 0;
|
||||||
|
|
@ -163,9 +214,8 @@ public function handle()
|
||||||
});
|
});
|
||||||
$this->allDatabaseUuids = $this->databases->pluck('uuid');
|
$this->allDatabaseUuids = $this->databases->pluck('uuid');
|
||||||
$this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid');
|
$this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid');
|
||||||
// Use eager-loaded relationships instead of querying in loop
|
$this->allServiceApplicationIds = $this->serviceApplicationsById->keys();
|
||||||
$this->allServiceApplicationIds = $this->services->flatMap(fn ($service) => $service->applications->pluck('id'));
|
$this->allServiceDatabaseIds = $this->serviceDatabasesById->keys();
|
||||||
$this->allServiceDatabaseIds = $this->services->flatMap(fn ($service) => $service->databases->pluck('id'));
|
|
||||||
|
|
||||||
foreach ($this->containers as $container) {
|
foreach ($this->containers as $container) {
|
||||||
$containerStatus = data_get($container, 'state', 'exited');
|
$containerStatus = data_get($container, 'state', 'exited');
|
||||||
|
|
@ -279,6 +329,151 @@ public function handle()
|
||||||
$this->checkLogDrainContainer();
|
$this->checkLogDrainContainer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function loadApplications(): Collection
|
||||||
|
{
|
||||||
|
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
|
||||||
|
|
||||||
|
$applications = ($standaloneDockerIds->isNotEmpty() || $swarmDockerIds->isNotEmpty())
|
||||||
|
? Application::withoutGlobalScope('withRelations')
|
||||||
|
->select([
|
||||||
|
'id',
|
||||||
|
'uuid',
|
||||||
|
'name',
|
||||||
|
'status',
|
||||||
|
'build_pack',
|
||||||
|
'docker_compose_raw',
|
||||||
|
'destination_id',
|
||||||
|
'destination_type',
|
||||||
|
'last_online_at',
|
||||||
|
])
|
||||||
|
->withCount('additional_servers')
|
||||||
|
->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
|
||||||
|
->get()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
$additionalApplicationIds = DB::table('additional_destinations')
|
||||||
|
->where('server_id', $this->server->id)
|
||||||
|
->pluck('application_id');
|
||||||
|
|
||||||
|
if ($additionalApplicationIds->isNotEmpty()) {
|
||||||
|
$applications = $applications->concat(
|
||||||
|
Application::withoutGlobalScope('withRelations')
|
||||||
|
->select([
|
||||||
|
'id',
|
||||||
|
'uuid',
|
||||||
|
'name',
|
||||||
|
'status',
|
||||||
|
'build_pack',
|
||||||
|
'docker_compose_raw',
|
||||||
|
'destination_id',
|
||||||
|
'destination_type',
|
||||||
|
'last_online_at',
|
||||||
|
])
|
||||||
|
->withCount('additional_servers')
|
||||||
|
->whereIn('id', $additionalApplicationIds)
|
||||||
|
->get()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $applications->unique('id')->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadPreviews(): Collection
|
||||||
|
{
|
||||||
|
$applicationIds = $this->applications->pluck('id');
|
||||||
|
|
||||||
|
if ($applicationIds->isEmpty()) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApplicationPreview::query()
|
||||||
|
->select([
|
||||||
|
'id',
|
||||||
|
'application_id',
|
||||||
|
'pull_request_id',
|
||||||
|
'status',
|
||||||
|
'last_online_at',
|
||||||
|
])
|
||||||
|
->whereIn('application_id', $applicationIds)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadServices(): Collection
|
||||||
|
{
|
||||||
|
return $this->server->services()
|
||||||
|
->select([
|
||||||
|
'id',
|
||||||
|
'server_id',
|
||||||
|
'uuid',
|
||||||
|
'docker_compose_raw',
|
||||||
|
])
|
||||||
|
->with([
|
||||||
|
'applications:id,service_id,status,last_online_at',
|
||||||
|
'databases:id,service_id,status,last_online_at,is_public,name',
|
||||||
|
])
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadDatabases(): Collection
|
||||||
|
{
|
||||||
|
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
|
||||||
|
if ($standaloneDockerIds->isEmpty() && $swarmDockerIds->isEmpty()) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
$databaseColumns = [
|
||||||
|
'id',
|
||||||
|
'uuid',
|
||||||
|
'name',
|
||||||
|
'status',
|
||||||
|
'is_public',
|
||||||
|
'destination_id',
|
||||||
|
'destination_type',
|
||||||
|
'last_online_at',
|
||||||
|
'restart_count',
|
||||||
|
'last_restart_at',
|
||||||
|
'last_restart_type',
|
||||||
|
];
|
||||||
|
|
||||||
|
return collect([
|
||||||
|
StandalonePostgresql::class,
|
||||||
|
StandaloneRedis::class,
|
||||||
|
StandaloneMongodb::class,
|
||||||
|
StandaloneMysql::class,
|
||||||
|
StandaloneMariadb::class,
|
||||||
|
StandaloneKeydb::class,
|
||||||
|
StandaloneDragonfly::class,
|
||||||
|
StandaloneClickhouse::class,
|
||||||
|
])->flatMap(function (string $databaseClass) use ($databaseColumns, $standaloneDockerIds, $swarmDockerIds) {
|
||||||
|
return $databaseClass::query()
|
||||||
|
->select($databaseColumns)
|
||||||
|
->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
|
||||||
|
->get();
|
||||||
|
})->filter(fn ($database) => data_get($database, 'name') !== 'coolify-db')->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serverDestinationIds(): array
|
||||||
|
{
|
||||||
|
if ($this->cachedDestinationIds !== null) {
|
||||||
|
return $this->cachedDestinationIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->cachedDestinationIds = [
|
||||||
|
StandaloneDocker::where('server_id', $this->server->id)->pluck('id'),
|
||||||
|
SwarmDocker::where('server_id', $this->server->id)->pluck('id'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scopeDestination($query, Collection $standaloneDockerIds, Collection $swarmDockerIds): void
|
||||||
|
{
|
||||||
|
$query->where(function ($query) use ($standaloneDockerIds) {
|
||||||
|
$query->where('destination_type', StandaloneDocker::class)
|
||||||
|
->whereIn('destination_id', $standaloneDockerIds);
|
||||||
|
})->orWhere(function ($query) use ($swarmDockerIds) {
|
||||||
|
$query->where('destination_type', SwarmDocker::class)
|
||||||
|
->whereIn('destination_id', $swarmDockerIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private function aggregateMultiContainerStatuses()
|
private function aggregateMultiContainerStatuses()
|
||||||
{
|
{
|
||||||
if ($this->applicationContainerStatuses->isEmpty()) {
|
if ($this->applicationContainerStatuses->isEmpty()) {
|
||||||
|
|
@ -286,7 +481,7 @@ private function aggregateMultiContainerStatuses()
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
|
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
|
||||||
$application = $this->applications->where('id', $applicationId)->first();
|
$application = $this->applicationsById->get((string) $applicationId);
|
||||||
if (! $application) {
|
if (! $application) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -307,8 +502,6 @@ private function aggregateMultiContainerStatuses()
|
||||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||||
$application->status = $aggregatedStatus;
|
$application->status = $aggregatedStatus;
|
||||||
$application->save();
|
$application->save();
|
||||||
} elseif ($aggregatedStatus) {
|
|
||||||
$application->update(['last_online_at' => now()]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -323,8 +516,6 @@ private function aggregateMultiContainerStatuses()
|
||||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||||
$application->status = $aggregatedStatus;
|
$application->status = $aggregatedStatus;
|
||||||
$application->save();
|
$application->save();
|
||||||
} elseif ($aggregatedStatus) {
|
|
||||||
$application->update(['last_online_at' => now()]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -343,7 +534,7 @@ private function aggregateServiceContainerStatuses()
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service = $this->services->where('id', $serviceId)->first();
|
$service = $this->servicesById->get((string) $serviceId);
|
||||||
if (! $service) {
|
if (! $service) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -351,9 +542,9 @@ private function aggregateServiceContainerStatuses()
|
||||||
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
|
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
|
||||||
$subResource = null;
|
$subResource = null;
|
||||||
if ($subType === 'application') {
|
if ($subType === 'application') {
|
||||||
$subResource = $service->applications->where('id', $subId)->first();
|
$subResource = $this->serviceApplicationsById->get((string) $subId);
|
||||||
} elseif ($subType === 'database') {
|
} elseif ($subType === 'database') {
|
||||||
$subResource = $service->databases->where('id', $subId)->first();
|
$subResource = $this->serviceDatabasesById->get((string) $subId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $subResource) {
|
if (! $subResource) {
|
||||||
|
|
@ -375,8 +566,6 @@ private function aggregateServiceContainerStatuses()
|
||||||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||||
$subResource->status = $aggregatedStatus;
|
$subResource->status = $aggregatedStatus;
|
||||||
$subResource->save();
|
$subResource->save();
|
||||||
} elseif ($aggregatedStatus) {
|
|
||||||
$subResource->update(['last_online_at' => now()]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -392,39 +581,31 @@ private function aggregateServiceContainerStatuses()
|
||||||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||||
$subResource->status = $aggregatedStatus;
|
$subResource->status = $aggregatedStatus;
|
||||||
$subResource->save();
|
$subResource->save();
|
||||||
} elseif ($aggregatedStatus) {
|
|
||||||
$subResource->update(['last_online_at' => now()]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function updateApplicationStatus(string $applicationId, string $containerStatus)
|
private function updateApplicationStatus(string $applicationId, string $containerStatus)
|
||||||
{
|
{
|
||||||
$application = $this->applications->where('id', $applicationId)->first();
|
$application = $this->applicationsById->get((string) $applicationId);
|
||||||
if (! $application) {
|
if (! $application) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($application->status !== $containerStatus) {
|
if ($application->status !== $containerStatus) {
|
||||||
$application->status = $containerStatus;
|
$application->status = $containerStatus;
|
||||||
$application->save();
|
$application->save();
|
||||||
} else {
|
|
||||||
$application->update(['last_online_at' => now()]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function updateApplicationPreviewStatus(string $applicationId, string $pullRequestId, string $containerStatus)
|
private function updateApplicationPreviewStatus(string $applicationId, string $pullRequestId, string $containerStatus)
|
||||||
{
|
{
|
||||||
$application = $this->previews->where('application_id', $applicationId)
|
$application = $this->previewsByKey->get($applicationId.':'.$pullRequestId);
|
||||||
->where('pull_request_id', $pullRequestId)
|
|
||||||
->first();
|
|
||||||
if (! $application) {
|
if (! $application) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($application->status !== $containerStatus) {
|
if ($application->status !== $containerStatus) {
|
||||||
$application->status = $containerStatus;
|
$application->status = $containerStatus;
|
||||||
$application->save();
|
$application->save();
|
||||||
} else {
|
|
||||||
$application->update(['last_online_at' => now()]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -472,9 +653,7 @@ private function updateNotFoundApplicationPreviewStatus()
|
||||||
$applicationId = $parts[0];
|
$applicationId = $parts[0];
|
||||||
$pullRequestId = $parts[1];
|
$pullRequestId = $parts[1];
|
||||||
|
|
||||||
$applicationPreview = $this->previews->where('application_id', $applicationId)
|
$applicationPreview = $this->previewsByKey->get($applicationId.':'.$pullRequestId);
|
||||||
->where('pull_request_id', $pullRequestId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($applicationPreview && ! str($applicationPreview->status)->startsWith('exited')) {
|
if ($applicationPreview && ! str($applicationPreview->status)->startsWith('exited')) {
|
||||||
$previewIdsToUpdate->push($applicationPreview->id);
|
$previewIdsToUpdate->push($applicationPreview->id);
|
||||||
|
|
@ -500,11 +679,11 @@ private function updateProxyStatus()
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Connect proxy to networks periodically (every 10 min) to avoid excessive job dispatches.
|
// Connect proxy to networks periodically as a safety net to avoid excessive job dispatches.
|
||||||
// On-demand triggers (new network, service deploy) use dispatchSync() and bypass this.
|
// On-demand triggers (new network, service deploy) use dispatchSync() and bypass this.
|
||||||
$proxyCacheKey = 'connect-proxy:'.$this->server->id;
|
$proxyCacheKey = 'connect-proxy:'.$this->server->id;
|
||||||
if (! Cache::has($proxyCacheKey)) {
|
if (! Cache::has($proxyCacheKey)) {
|
||||||
Cache::put($proxyCacheKey, true, 600);
|
Cache::put($proxyCacheKey, true, config('constants.proxy.connect_networks_interval_seconds', 3600));
|
||||||
ConnectProxyToNetworksJob::dispatch($this->server);
|
ConnectProxyToNetworksJob::dispatch($this->server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -513,15 +692,13 @@ private function updateProxyStatus()
|
||||||
|
|
||||||
private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false)
|
private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false)
|
||||||
{
|
{
|
||||||
$database = $this->databases->where('uuid', $databaseUuid)->first();
|
$database = $this->databasesByUuid->get($databaseUuid);
|
||||||
if (! $database) {
|
if (! $database) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($database->status !== $containerStatus) {
|
if ($database->status !== $containerStatus) {
|
||||||
$database->status = $containerStatus;
|
$database->status = $containerStatus;
|
||||||
$database->save();
|
$database->save();
|
||||||
} else {
|
|
||||||
$database->update(['last_online_at' => now()]);
|
|
||||||
}
|
}
|
||||||
if ($this->isRunning($containerStatus) && $tcpProxy) {
|
if ($this->isRunning($containerStatus) && $tcpProxy) {
|
||||||
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
|
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
|
||||||
|
|
@ -556,7 +733,7 @@ private function updateNotFoundDatabaseStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
$notFoundDatabaseUuids->each(function ($databaseUuid) {
|
$notFoundDatabaseUuids->each(function ($databaseUuid) {
|
||||||
$database = $this->databases->where('uuid', $databaseUuid)->first();
|
$database = $this->databasesByUuid->get($databaseUuid);
|
||||||
if ($database) {
|
if ($database) {
|
||||||
if (! str($database->status)->startsWith('exited')) {
|
if (! str($database->status)->startsWith('exited')) {
|
||||||
$database->update([
|
$database->update([
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,15 @@
|
||||||
use App\Models\ScheduledTask;
|
use App\Models\ScheduledTask;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
use Cron\CronExpression;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Redis;
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
|
@ -22,6 +23,8 @@ class ScheduledJobManager implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
private const CHUNK_SIZE = 100;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The time when this job execution started.
|
* The time when this job execution started.
|
||||||
* Used to ensure all scheduled items are evaluated against the same point in time.
|
* Used to ensure all scheduled items are evaluated against the same point in time.
|
||||||
|
|
@ -37,17 +40,7 @@ class ScheduledJobManager implements ShouldQueue
|
||||||
*/
|
*/
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->onQueue($this->determineQueue());
|
$this->onQueue(crons_queue());
|
||||||
}
|
|
||||||
|
|
||||||
private function determineQueue(): string
|
|
||||||
{
|
|
||||||
$preferredQueue = 'crons';
|
|
||||||
$fallbackQueue = 'high';
|
|
||||||
|
|
||||||
$configuredQueues = explode(',', env('HORIZON_QUEUES', 'high,default'));
|
|
||||||
|
|
||||||
return in_array($preferredQueue, $configuredQueues) ? $preferredQueue : $fallbackQueue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -106,21 +99,11 @@ public function handle(): void
|
||||||
'execution_time' => $this->executionTime->toIso8601String(),
|
'execution_time' => $this->executionTime->toIso8601String(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Process backups - don't let failures stop task processing
|
// Process scheduled backups and tasks together so neither type starves the other.
|
||||||
try {
|
try {
|
||||||
$this->processScheduledBackups();
|
$this->processScheduledBackupsAndTasks();
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::channel('scheduled-errors')->error('Failed to process scheduled backups', [
|
Log::channel('scheduled-errors')->error('Failed to process scheduled backups and tasks', [
|
||||||
'error' => $e->getMessage(),
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process tasks - don't let failures stop the job manager
|
|
||||||
try {
|
|
||||||
$this->processScheduledTasks();
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::channel('scheduled-errors')->error('Failed to process scheduled tasks', [
|
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'trace' => $e->getTraceAsString(),
|
'trace' => $e->getTraceAsString(),
|
||||||
]);
|
]);
|
||||||
|
|
@ -151,40 +134,155 @@ public function handle(): void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function processScheduledBackups(): void
|
private function processScheduledBackupsAndTasks(): void
|
||||||
{
|
{
|
||||||
$backups = ScheduledDatabaseBackup::with(['database'])
|
$lastBackupId = 0;
|
||||||
|
$lastTaskId = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
$backups = $this->scheduledBackupQuery($lastBackupId)->get();
|
||||||
|
$tasks = $this->scheduledTaskQuery($lastTaskId)->get();
|
||||||
|
|
||||||
|
if ($backups->isNotEmpty()) {
|
||||||
|
$lastBackupId = $backups->last()->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tasks->isNotEmpty()) {
|
||||||
|
$lastTaskId = $tasks->last()->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->processInterleavedDueSchedules(
|
||||||
|
$this->dueScheduledBackups($backups),
|
||||||
|
$this->dueScheduledTasks($tasks),
|
||||||
|
);
|
||||||
|
} while ($backups->isNotEmpty() || $tasks->isNotEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{backup: ScheduledDatabaseBackup, server: Server}> $dueBackups
|
||||||
|
* @param array<int, array{task: ScheduledTask, server: Server}> $dueTasks
|
||||||
|
*/
|
||||||
|
private function processInterleavedDueSchedules(array $dueBackups, array $dueTasks): void
|
||||||
|
{
|
||||||
|
$maxCount = max(count($dueBackups), count($dueTasks));
|
||||||
|
|
||||||
|
for ($index = 0; $index < $maxCount; $index++) {
|
||||||
|
if (isset($dueBackups[$index])) {
|
||||||
|
$this->processScheduledBackup($dueBackups[$index]['backup'], $dueBackups[$index]['server']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($dueTasks[$index])) {
|
||||||
|
$this->processScheduledTask($dueTasks[$index]['task'], $dueTasks[$index]['server']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scheduledBackupQuery(int $lastBackupId): Builder
|
||||||
|
{
|
||||||
|
return ScheduledDatabaseBackup::with(['database', 'team.subscription'])
|
||||||
->where('enabled', true)
|
->where('enabled', true)
|
||||||
->get();
|
->where('id', '>', $lastBackupId)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(self::CHUNK_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scheduledTaskQuery(int $lastTaskId): Builder
|
||||||
|
{
|
||||||
|
return ScheduledTask::with([
|
||||||
|
'service.destination.server.settings',
|
||||||
|
'service.destination.server.team.subscription',
|
||||||
|
'application.destination.server.settings',
|
||||||
|
'application.destination.server.team.subscription',
|
||||||
|
])
|
||||||
|
->where('enabled', true)
|
||||||
|
->where('id', '>', $lastTaskId)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(self::CHUNK_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<ScheduledDatabaseBackup> $backups
|
||||||
|
* @return array<int, array{backup: ScheduledDatabaseBackup, server: Server}>
|
||||||
|
*/
|
||||||
|
private function dueScheduledBackups(iterable $backups): array
|
||||||
|
{
|
||||||
|
$dueBackups = [];
|
||||||
|
|
||||||
foreach ($backups as $backup) {
|
foreach ($backups as $backup) {
|
||||||
try {
|
try {
|
||||||
$server = $backup->server();
|
$server = $backup->server();
|
||||||
$skipReason = $this->getBackupSkipReason($backup, $server);
|
|
||||||
if ($skipReason !== null) {
|
if (blank(data_get($backup, 'database')) || blank($server)) {
|
||||||
$this->skippedCount++;
|
$this->processScheduledBackup($backup, $server);
|
||||||
$this->logSkip('backup', $skipReason, [
|
|
||||||
'backup_id' => $backup->id,
|
|
||||||
'database_id' => $backup->database_id,
|
|
||||||
'database_type' => $backup->database_type,
|
|
||||||
'team_id' => $backup->team_id ?? null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
|
if ($this->isDueCandidateBeforeExpensiveChecks($backup->frequency, $server, "scheduled-backup:{$backup->id}")) {
|
||||||
|
$dueBackups[] = [
|
||||||
if (validate_timezone($serverTimezone) === false) {
|
'backup' => $backup,
|
||||||
$serverTimezone = config('app.timezone');
|
'server' => $server,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::channel('scheduled-errors')->error('Error prechecking backup', [
|
||||||
|
'backup_id' => $backup->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$frequency = $backup->frequency;
|
return $dueBackups;
|
||||||
if (isset(VALID_CRON_STRINGS[$frequency])) {
|
|
||||||
$frequency = VALID_CRON_STRINGS[$frequency];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldRunCronNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}", $this->executionTime)) {
|
/**
|
||||||
|
* @param iterable<ScheduledTask> $tasks
|
||||||
|
* @return array<int, array{task: ScheduledTask, server: Server}>
|
||||||
|
*/
|
||||||
|
private function dueScheduledTasks(iterable $tasks): array
|
||||||
|
{
|
||||||
|
$dueTasks = [];
|
||||||
|
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
try {
|
||||||
|
$server = $task->server();
|
||||||
|
|
||||||
|
if (blank($server) || (! $task->service && ! $task->application)) {
|
||||||
|
$this->processScheduledTask($task, $server);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isDueCandidateBeforeExpensiveChecks($task->frequency, $server, "scheduled-task:{$task->id}")) {
|
||||||
|
$dueTasks[] = [
|
||||||
|
'task' => $task,
|
||||||
|
'server' => $server,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::channel('scheduled-errors')->error('Error prechecking task', [
|
||||||
|
'task_id' => $task->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dueTasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processScheduledBackup(ScheduledDatabaseBackup $backup, ?Server $precheckedServer = null): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$server = $precheckedServer ?? $backup->server();
|
||||||
|
$skipReason = $this->getBackupSkipReason($backup, $server);
|
||||||
|
if ($skipReason !== null) {
|
||||||
|
$this->skippedCount++;
|
||||||
|
$this->logBackupSkip($backup, $skipReason);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->shouldDispatch($backup->frequency, $server, "scheduled-backup:{$backup->id}")) {
|
||||||
DatabaseBackupJob::dispatch($backup);
|
DatabaseBackupJob::dispatch($backup);
|
||||||
$this->dispatchedCount++;
|
$this->dispatchedCount++;
|
||||||
Log::channel('scheduled')->info('Backup dispatched', [
|
Log::channel('scheduled')->info('Backup dispatched', [
|
||||||
|
|
@ -202,57 +300,29 @@ private function processScheduledBackups(): void
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private function processScheduledTasks(): void
|
private function processScheduledTask(ScheduledTask $task, ?Server $precheckedServer = null): void
|
||||||
{
|
{
|
||||||
$tasks = ScheduledTask::with(['service', 'application'])
|
|
||||||
->where('enabled', true)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($tasks as $task) {
|
|
||||||
try {
|
try {
|
||||||
$server = $task->server();
|
$server = $precheckedServer ?? $task->server();
|
||||||
|
|
||||||
// Phase 1: Critical checks (always — cheap, handles orphans and infra issues)
|
|
||||||
$criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
|
$criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
|
||||||
if ($criticalSkip !== null) {
|
if ($criticalSkip !== null) {
|
||||||
$this->skippedCount++;
|
$this->skippedCount++;
|
||||||
$this->logSkip('task', $criticalSkip, [
|
$this->logTaskSkip($task, $criticalSkip, $server);
|
||||||
'task_id' => $task->id,
|
|
||||||
'task_name' => $task->name,
|
|
||||||
'team_id' => $server?->team_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
|
if (! $this->shouldDispatch($task->frequency, $server, "scheduled-task:{$task->id}")) {
|
||||||
|
return;
|
||||||
if (validate_timezone($serverTimezone) === false) {
|
|
||||||
$serverTimezone = config('app.timezone');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$frequency = $task->frequency;
|
|
||||||
if (isset(VALID_CRON_STRINGS[$frequency])) {
|
|
||||||
$frequency = VALID_CRON_STRINGS[$frequency];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! shouldRunCronNow($frequency, $serverTimezone, "scheduled-task:{$task->id}", $this->executionTime)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources)
|
|
||||||
$runtimeSkip = $this->getTaskRuntimeSkipReason($task);
|
$runtimeSkip = $this->getTaskRuntimeSkipReason($task);
|
||||||
if ($runtimeSkip !== null) {
|
if ($runtimeSkip !== null) {
|
||||||
$this->skippedCount++;
|
$this->skippedCount++;
|
||||||
$this->logSkip('task', $runtimeSkip, [
|
$this->logTaskSkip($task, $runtimeSkip, $server);
|
||||||
'task_id' => $task->id,
|
|
||||||
'task_name' => $task->name,
|
|
||||||
'team_id' => $server->team_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduledTaskJob::dispatch($task);
|
ScheduledTaskJob::dispatch($task);
|
||||||
|
|
@ -270,7 +340,6 @@ private function processScheduledTasks(): void
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string
|
private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string
|
||||||
{
|
{
|
||||||
|
|
@ -337,10 +406,16 @@ private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string
|
||||||
|
|
||||||
private function processDockerCleanups(): void
|
private function processDockerCleanups(): void
|
||||||
{
|
{
|
||||||
// Get all servers that need cleanup checks
|
$this->getServersForCleanupQuery()
|
||||||
$servers = $this->getServersForCleanup();
|
->chunkById(self::CHUNK_SIZE, function ($servers): void {
|
||||||
|
|
||||||
foreach ($servers as $server) {
|
foreach ($servers as $server) {
|
||||||
|
$this->processDockerCleanup($server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processDockerCleanup(Server $server): void
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
$skipReason = $this->getDockerCleanupSkipReason($server);
|
$skipReason = $this->getDockerCleanupSkipReason($server);
|
||||||
if ($skipReason !== null) {
|
if ($skipReason !== null) {
|
||||||
|
|
@ -351,21 +426,12 @@ private function processDockerCleanups(): void
|
||||||
'team_id' => $server->team_id,
|
'team_id' => $server->team_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
continue;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
|
|
||||||
if (validate_timezone($serverTimezone) === false) {
|
|
||||||
$serverTimezone = config('app.timezone');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
|
$frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
|
||||||
if (isset(VALID_CRON_STRINGS[$frequency])) {
|
|
||||||
$frequency = VALID_CRON_STRINGS[$frequency];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the frozen execution time for consistent evaluation
|
if ($this->shouldDispatch($frequency, $server, "docker-cleanup:{$server->id}")) {
|
||||||
if (shouldRunCronNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}", $this->executionTime)) {
|
|
||||||
DockerCleanupJob::dispatch(
|
DockerCleanupJob::dispatch(
|
||||||
$server,
|
$server,
|
||||||
false,
|
false,
|
||||||
|
|
@ -387,21 +453,23 @@ private function processDockerCleanups(): void
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private function getServersForCleanup(): Collection
|
private function getServersForCleanupQuery(): Builder
|
||||||
{
|
{
|
||||||
$query = Server::with('settings')
|
$query = Server::with('settings')
|
||||||
->where('ip', '!=', '1.2.3.4');
|
->where('ip', '!=', '1.2.3.4');
|
||||||
|
|
||||||
if (isCloud()) {
|
if (isCloud()) {
|
||||||
$servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
|
$query
|
||||||
$own = Team::find(0)->servers()->with('settings')->get();
|
->with('team.subscription')
|
||||||
|
->where(function (Builder $query): void {
|
||||||
return $servers->merge($own);
|
$query
|
||||||
|
->where('team_id', 0)
|
||||||
|
->orWhereRelation('team.subscription', 'stripe_invoice_paid', true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->get();
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getDockerCleanupSkipReason(Server $server): ?string
|
private function getDockerCleanupSkipReason(Server $server): ?string
|
||||||
|
|
@ -428,4 +496,71 @@ private function logSkip(string $type, string $reason, array $context = []): voi
|
||||||
'execution_time' => $this->executionTime?->toIso8601String(),
|
'execution_time' => $this->executionTime?->toIso8601String(),
|
||||||
], $context));
|
], $context));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldDispatch(string $frequency, Server $server, string $dedupKey): bool
|
||||||
|
{
|
||||||
|
return shouldRunCronNow(
|
||||||
|
$this->normalizeFrequency($frequency),
|
||||||
|
$this->serverTimezone($server),
|
||||||
|
$dedupKey,
|
||||||
|
$this->executionTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isDueCandidateBeforeExpensiveChecks(string $frequency, Server $server, string $dedupKey): bool
|
||||||
|
{
|
||||||
|
$cron = new CronExpression($this->normalizeFrequency($frequency));
|
||||||
|
$executionTime = ($this->executionTime ?? Carbon::now())->copy()->setTimezone($this->serverTimezone($server));
|
||||||
|
$lastDispatched = Cache::get($dedupKey);
|
||||||
|
$previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
|
||||||
|
|
||||||
|
if ($lastDispatched === null) {
|
||||||
|
$isDue = $cron->isDue($executionTime);
|
||||||
|
|
||||||
|
if (! $isDue) {
|
||||||
|
Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $isDue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$shouldFire = $previousDue->gt(Carbon::parse($lastDispatched));
|
||||||
|
|
||||||
|
if (! $shouldFire) {
|
||||||
|
Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $shouldFire;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeFrequency(string $frequency): string
|
||||||
|
{
|
||||||
|
return VALID_CRON_STRINGS[$frequency] ?? $frequency;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serverTimezone(Server $server): string
|
||||||
|
{
|
||||||
|
$timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
|
||||||
|
|
||||||
|
return validate_timezone($timezone) ? $timezone : config('app.timezone');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logBackupSkip(ScheduledDatabaseBackup $backup, string $reason): void
|
||||||
|
{
|
||||||
|
$this->logSkip('backup', $reason, [
|
||||||
|
'backup_id' => $backup->id,
|
||||||
|
'database_id' => $backup->database_id,
|
||||||
|
'database_type' => $backup->database_type,
|
||||||
|
'team_id' => $backup->team_id ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logTaskSkip(ScheduledTask $task, string $reason, ?Server $server): void
|
||||||
|
{
|
||||||
|
$this->logSkip('task', $reason, [
|
||||||
|
'task_id' => $task->id,
|
||||||
|
'task_name' => $task->name,
|
||||||
|
'team_id' => $server?->team_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
*/
|
*/
|
||||||
public $timeout = 300;
|
public $timeout = 300;
|
||||||
|
|
||||||
public Team $team;
|
public ?Team $team = null;
|
||||||
|
|
||||||
public ?Server $server = null;
|
public ?Server $server = null;
|
||||||
|
|
||||||
public ScheduledTask $task;
|
public ScheduledTask $task;
|
||||||
|
|
||||||
public Application|Service $resource;
|
public Application|Service|null $resource = null;
|
||||||
|
|
||||||
public ?ScheduledTaskExecution $task_log = null;
|
public ?ScheduledTaskExecution $task_log = null;
|
||||||
|
|
||||||
|
|
@ -61,25 +61,34 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
|
|
||||||
public array $containers = [];
|
public array $containers = [];
|
||||||
|
|
||||||
public string $server_timezone;
|
public string $server_timezone = 'UTC';
|
||||||
|
|
||||||
public function __construct($task)
|
public function __construct(ScheduledTask $task)
|
||||||
{
|
{
|
||||||
$this->onQueue('high');
|
$this->onQueue(crons_queue());
|
||||||
|
|
||||||
$this->task = $task;
|
$this->task = $task;
|
||||||
if ($service = $task->service()->first()) {
|
$this->timeout = $this->task->timeout ?? 300;
|
||||||
$this->resource = $service;
|
}
|
||||||
} elseif ($application = $task->application()->first()) {
|
|
||||||
$this->resource = $application;
|
private function initializeExecutionContext(): void
|
||||||
|
{
|
||||||
|
$this->task->loadMissing([
|
||||||
|
'service.destination.server.settings',
|
||||||
|
'application.destination.server.settings',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($this->task->service) {
|
||||||
|
$this->resource = $this->task->service;
|
||||||
|
} elseif ($this->task->application) {
|
||||||
|
$this->resource = $this->task->application;
|
||||||
} else {
|
} else {
|
||||||
throw new \RuntimeException('ScheduledTaskJob failed: No resource found.');
|
throw new \RuntimeException('ScheduledTaskJob failed: No resource found.');
|
||||||
}
|
}
|
||||||
$this->team = Team::findOrFail($task->team_id);
|
|
||||||
$this->server_timezone = $this->getServerTimezone();
|
|
||||||
|
|
||||||
// Set timeout from task configuration
|
$this->team = Team::findOrFail($this->task->team_id);
|
||||||
$this->timeout = $this->task->timeout ?? 300;
|
$this->server_timezone = $this->getServerTimezone();
|
||||||
|
$this->server = $this->resource->destination->server;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getServerTimezone(): string
|
private function getServerTimezone(): string
|
||||||
|
|
@ -98,6 +107,8 @@ public function handle(): void
|
||||||
$startTime = Carbon::now();
|
$startTime = Carbon::now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$this->initializeExecutionContext();
|
||||||
|
|
||||||
$this->task_log = ScheduledTaskExecution::create([
|
$this->task_log = ScheduledTaskExecution::create([
|
||||||
'scheduled_task_id' => $this->task->id,
|
'scheduled_task_id' => $this->task->id,
|
||||||
'started_at' => $startTime,
|
'started_at' => $startTime,
|
||||||
|
|
@ -107,8 +118,6 @@ public function handle(): void
|
||||||
// Store execution ID for timeout handling
|
// Store execution ID for timeout handling
|
||||||
$this->executionId = $this->task_log->id;
|
$this->executionId = $this->task_log->id;
|
||||||
|
|
||||||
$this->server = $this->resource->destination->server;
|
|
||||||
|
|
||||||
if ($this->resource->type() === 'application') {
|
if ($this->resource->type() === 'application') {
|
||||||
$containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0);
|
$containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0);
|
||||||
if ($containers->count() > 0) {
|
if ($containers->count() > 0) {
|
||||||
|
|
@ -179,7 +188,10 @@ public function handle(): void
|
||||||
// Re-throw to trigger Laravel's retry mechanism with backoff
|
// Re-throw to trigger Laravel's retry mechanism with backoff
|
||||||
throw $e;
|
throw $e;
|
||||||
} finally {
|
} finally {
|
||||||
|
if ($this->team) {
|
||||||
ScheduledTaskDone::dispatch($this->team->id);
|
ScheduledTaskDone::dispatch($this->team->id);
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->task_log) {
|
if ($this->task_log) {
|
||||||
$finishedAt = Carbon::now();
|
$finishedAt = Carbon::now();
|
||||||
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
|
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
|
||||||
|
|
@ -205,6 +217,8 @@ public function backoff(): array
|
||||||
*/
|
*/
|
||||||
public function failed(?\Throwable $exception): void
|
public function failed(?\Throwable $exception): void
|
||||||
{
|
{
|
||||||
|
$this->team ??= Team::find($this->task->team_id);
|
||||||
|
|
||||||
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
|
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
|
||||||
'job' => 'ScheduledTaskJob',
|
'job' => 'ScheduledTaskJob',
|
||||||
'task_id' => $this->task->uuid,
|
'task_id' => $this->task->uuid,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Rules\SafeWebhookUrl;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
|
@ -44,7 +45,7 @@ public function handle(): void
|
||||||
{
|
{
|
||||||
$validator = Validator::make(
|
$validator = Validator::make(
|
||||||
['webhook_url' => $this->webhookUrl],
|
['webhook_url' => $this->webhookUrl],
|
||||||
['webhook_url' => ['required', 'url', new \App\Rules\SafeWebhookUrl]]
|
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Services\ConfigurationRepository;
|
use App\Services\ConfigurationRepository;
|
||||||
|
use App\Support\ValidationPatterns;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Livewire\Attributes\Url;
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
@ -212,6 +213,23 @@ private function updateServerDetails()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'remoteServerName' => 'required|string',
|
||||||
|
'remoteServerHost' => 'required|string',
|
||||||
|
'remoteServerPort' => 'required|integer|min:1|max:65535',
|
||||||
|
'remoteServerUser' => ValidationPatterns::serverUsernameRules(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...ValidationPatterns::serverUsernameMessages('remoteServerUser', 'SSH User'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function getProxyType()
|
public function getProxyType()
|
||||||
{
|
{
|
||||||
$this->selectProxy(ProxyTypes::TRAEFIK->value);
|
$this->selectProxy(ProxyTypes::TRAEFIK->value);
|
||||||
|
|
@ -274,12 +292,7 @@ public function savePrivateKey()
|
||||||
|
|
||||||
public function saveServer()
|
public function saveServer()
|
||||||
{
|
{
|
||||||
$this->validate([
|
$this->validate();
|
||||||
'remoteServerName' => 'required|string',
|
|
||||||
'remoteServerHost' => 'required|string',
|
|
||||||
'remoteServerPort' => 'required|integer',
|
|
||||||
'remoteServerUser' => 'required|string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->privateKey = formatPrivateKey($this->privateKey);
|
$this->privateKey = formatPrivateKey($this->privateKey);
|
||||||
$foundServer = Server::whereIp($this->remoteServerHost)->first();
|
$foundServer = Server::whereIp($this->remoteServerHost)->first();
|
||||||
|
|
@ -465,10 +478,10 @@ public function showNewResource()
|
||||||
|
|
||||||
public function saveAndValidateServer()
|
public function saveAndValidateServer()
|
||||||
{
|
{
|
||||||
$this->validate([
|
$this->validate(array_intersect_key($this->rules(), array_flip([
|
||||||
'remoteServerPort' => 'required|integer|min:1|max:65535',
|
'remoteServerPort',
|
||||||
'remoteServerUser' => 'required|string',
|
'remoteServerUser',
|
||||||
]);
|
])));
|
||||||
|
|
||||||
$this->createdServer->update([
|
$this->createdServer->update([
|
||||||
'port' => $this->remoteServerPort,
|
'port' => $this->remoteServerPort,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Livewire\Destination;
|
namespace App\Livewire\Destination;
|
||||||
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
|
@ -11,9 +12,15 @@ class Index extends Component
|
||||||
#[Locked]
|
#[Locked]
|
||||||
public $servers;
|
public $servers;
|
||||||
|
|
||||||
public function mount()
|
#[Locked]
|
||||||
|
public Collection $destinations;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->servers = Server::isUsable()->get();
|
$this->servers = Server::isUsable()->get();
|
||||||
|
$this->destinations = $this->servers
|
||||||
|
->flatMap(fn (Server $server) => $server->standaloneDockers->concat($server->swarmDockers))
|
||||||
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|
|
||||||
|
|
@ -33,44 +33,49 @@ class Docker extends Component
|
||||||
#[Validate(['required', 'boolean'])]
|
#[Validate(['required', 'boolean'])]
|
||||||
public bool $isSwarm = false;
|
public bool $isSwarm = false;
|
||||||
|
|
||||||
public function mount(?string $server_id = null)
|
public function mount(?string $server_id = null): void
|
||||||
{
|
{
|
||||||
$this->network = new Cuid2;
|
$this->network = (string) new Cuid2;
|
||||||
$this->servers = Server::isUsable()->get();
|
$this->servers = Server::isUsable()->get();
|
||||||
if ($server_id) {
|
|
||||||
$foundServer = $this->servers->find($server_id) ?: $this->servers->first();
|
if (filled($server_id)) {
|
||||||
if (! $foundServer) {
|
$this->selectedServer = Server::ownedByCurrentTeam()->whereKey($server_id)->firstOrFail();
|
||||||
throw new \Exception('Server not found.');
|
|
||||||
|
if (! $this->servers->contains('id', $this->selectedServer->id)) {
|
||||||
|
$this->servers->push($this->selectedServer);
|
||||||
}
|
}
|
||||||
$this->selectedServer = $foundServer;
|
|
||||||
$this->serverId = $this->selectedServer->id;
|
$this->serverId = (string) $this->selectedServer->id;
|
||||||
} else {
|
} else {
|
||||||
$foundServer = $this->servers->first();
|
$foundServer = $this->servers->first();
|
||||||
if (! $foundServer) {
|
if (! $foundServer) {
|
||||||
throw new \Exception('Server not found.');
|
throw new \Exception('Server not found.');
|
||||||
}
|
}
|
||||||
$this->selectedServer = $foundServer;
|
$this->selectedServer = $foundServer;
|
||||||
$this->serverId = $this->selectedServer->id;
|
$this->serverId = (string) $this->selectedServer->id;
|
||||||
}
|
}
|
||||||
$this->generateName();
|
$this->generateName();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedServerId()
|
public function updatedServerId(): void
|
||||||
{
|
{
|
||||||
$this->selectedServer = $this->servers->find($this->serverId);
|
$this->selectedServer = $this->servers->find($this->serverId);
|
||||||
|
if (! $this->selectedServer) {
|
||||||
|
throw new \Exception('Server not found.');
|
||||||
|
}
|
||||||
$this->generateName();
|
$this->generateName();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generateName()
|
public function generateName(): void
|
||||||
{
|
{
|
||||||
$name = data_get($this->selectedServer, 'name', new Cuid2);
|
$name = data_get($this->selectedServer, 'name', new Cuid2);
|
||||||
$this->name = str("{$name}-{$this->network}")->kebab();
|
$this->name = str("{$name}-{$this->network}")->kebab();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit(): mixed
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('create', StandaloneDocker::class);
|
$this->authorize('create', $this->isSwarm ? SwarmDocker::class : StandaloneDocker::class);
|
||||||
$this->validate();
|
$this->validate();
|
||||||
if ($this->isSwarm) {
|
if ($this->isSwarm) {
|
||||||
$found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first();
|
$found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first();
|
||||||
|
|
|
||||||
125
app/Livewire/Destination/Resources.php
Normal file
125
app/Livewire/Destination/Resources.php
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Destination;
|
||||||
|
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\BaseModel;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\StandaloneClickhouse;
|
||||||
|
use App\Models\StandaloneDocker;
|
||||||
|
use App\Models\StandaloneDragonfly;
|
||||||
|
use App\Models\StandaloneKeydb;
|
||||||
|
use App\Models\StandaloneMariadb;
|
||||||
|
use App\Models\StandaloneMongodb;
|
||||||
|
use App\Models\StandaloneMysql;
|
||||||
|
use App\Models\StandalonePostgresql;
|
||||||
|
use App\Models\StandaloneRedis;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Resources extends Component
|
||||||
|
{
|
||||||
|
#[Locked]
|
||||||
|
public $destination;
|
||||||
|
|
||||||
|
public array $resources = [];
|
||||||
|
|
||||||
|
public function mount(string $destination_uuid)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$destination = find_destination_for_current_team($destination_uuid);
|
||||||
|
if (! $destination) {
|
||||||
|
return redirect()->route('destination.index');
|
||||||
|
}
|
||||||
|
if (! $destination instanceof StandaloneDocker) {
|
||||||
|
return redirect()->route('destination.show', ['destination_uuid' => $destination->uuid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->destination = $destination;
|
||||||
|
$this->loadResources();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load applications, services, and database resources deployed to the standalone Docker destination.
|
||||||
|
*
|
||||||
|
* @return void Populates the resources property for display.
|
||||||
|
*/
|
||||||
|
public function loadResources(): void
|
||||||
|
{
|
||||||
|
$this->resources = $this->collectResources([
|
||||||
|
$this->destination->applications,
|
||||||
|
$this->destination->services,
|
||||||
|
$this->destination->postgresqls,
|
||||||
|
$this->destination->redis,
|
||||||
|
$this->destination->mongodbs,
|
||||||
|
$this->destination->mysqls,
|
||||||
|
$this->destination->mariadbs,
|
||||||
|
$this->destination->keydbs,
|
||||||
|
$this->destination->dragonflies,
|
||||||
|
$this->destination->clickhouses,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, iterable<Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse>> $groups
|
||||||
|
* @return array<int, array{uuid:string,type:string,name:string,project:string|null,environment:string|null,url:string|null,search:string}>
|
||||||
|
*/
|
||||||
|
protected function collectResources(array $groups): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
foreach ($groups as $group) {
|
||||||
|
foreach ($group as $resource) {
|
||||||
|
$rows[] = $this->resourceRow($resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource
|
||||||
|
* @return array{uuid:string,type:string,name:string,project:string|null,environment:string|null,url:string|null,search:string}
|
||||||
|
*/
|
||||||
|
protected function resourceRow(BaseModel $resource): array
|
||||||
|
{
|
||||||
|
$type = match (true) {
|
||||||
|
$resource instanceof Application => 'application',
|
||||||
|
$resource instanceof Service => 'service',
|
||||||
|
default => 'database',
|
||||||
|
};
|
||||||
|
$environment = $resource->environment;
|
||||||
|
$project = $environment?->project;
|
||||||
|
$routeName = "project.{$type}.configuration";
|
||||||
|
$url = ($project && $environment)
|
||||||
|
? route($routeName, [
|
||||||
|
'project_uuid' => $project->uuid,
|
||||||
|
'environment_uuid' => $environment->uuid,
|
||||||
|
"{$type}_uuid" => $resource->uuid,
|
||||||
|
])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'uuid' => $resource->uuid,
|
||||||
|
'type' => $type,
|
||||||
|
'name' => $resource->name,
|
||||||
|
'project' => $project?->name,
|
||||||
|
'environment' => $environment?->name,
|
||||||
|
'url' => $url,
|
||||||
|
'search' => strtolower(implode(' ', array_filter([
|
||||||
|
$type,
|
||||||
|
$resource->name,
|
||||||
|
$project?->name,
|
||||||
|
$environment?->name,
|
||||||
|
]))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.destination.resources');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use DanHarrin\LivewireRateLimiting\WithRateLimiting;
|
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Livewire\Attributes\Validate;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Help extends Component
|
|
||||||
{
|
|
||||||
use WithRateLimiting;
|
|
||||||
|
|
||||||
#[Validate(['required', 'min:10', 'max:1000'])]
|
|
||||||
public string $description;
|
|
||||||
|
|
||||||
#[Validate(['required', 'min:3', 'max:600'])]
|
|
||||||
public string $subject;
|
|
||||||
|
|
||||||
public function submit()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->validate();
|
|
||||||
$this->rateLimit(3, 30);
|
|
||||||
|
|
||||||
$settings = instanceSettings();
|
|
||||||
$mail = new MailMessage;
|
|
||||||
$mail->view(
|
|
||||||
'emails.help',
|
|
||||||
[
|
|
||||||
'description' => $this->description,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
$mail->subject("[HELP]: {$this->subject}");
|
|
||||||
$type = set_transanctional_email_settings($settings);
|
|
||||||
|
|
||||||
// Sending feedback through Cloud API
|
|
||||||
if (blank($type)) {
|
|
||||||
$url = 'https://app.coolify.io/api/feedback';
|
|
||||||
Http::post($url, [
|
|
||||||
'content' => 'User: `'.auth()->user()?->email.'` with subject: `'.$this->subject.'` has the following problem: `'.$this->description.'`',
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
send_user_an_email($mail, auth()->user()?->email, 'feedback@coollabs.io');
|
|
||||||
}
|
|
||||||
$this->dispatch('success', 'Feedback sent.', 'We will get in touch with you as soon as possible.');
|
|
||||||
$this->reset('description', 'subject');
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return handleError($e, $this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.help')->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
13
app/Livewire/Profile/Appearance.php
Normal file
13
app/Livewire/Profile/Appearance.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Profile;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Appearance extends Component
|
||||||
|
{
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.profile.appearance');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,6 +87,9 @@ class Advanced extends Component
|
||||||
#[Validate(['boolean'])]
|
#[Validate(['boolean'])]
|
||||||
public bool $isConnectToDockerNetworkEnabled = false;
|
public bool $isConnectToDockerNetworkEnabled = false;
|
||||||
|
|
||||||
|
#[Validate(['integer', 'min:0'])]
|
||||||
|
public int $maxRestartCount = 10;
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
@ -149,6 +152,7 @@ public function syncData(bool $toModel = false)
|
||||||
$this->disableBuildCache = $this->application->settings->disable_build_cache;
|
$this->disableBuildCache = $this->application->settings->disable_build_cache;
|
||||||
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
|
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
|
||||||
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
|
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
|
||||||
|
$this->maxRestartCount = $this->application->max_restart_count ?? 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load stop_grace_period separately since it has its own save handler
|
// Load stop_grace_period separately since it has its own save handler
|
||||||
|
|
@ -289,6 +293,21 @@ public function saveStopGracePeriod()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function saveMaxRestartCount()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
|
$this->validate([
|
||||||
|
'maxRestartCount' => 'integer|min:0',
|
||||||
|
]);
|
||||||
|
$this->application->max_restart_count = $this->maxRestartCount;
|
||||||
|
$this->application->save();
|
||||||
|
$this->dispatch('success', 'Max restart count saved.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.project.application.advanced');
|
return view('livewire.project.application.advanced');
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,10 @@ class Configuration extends Component
|
||||||
|
|
||||||
public $servers;
|
public $servers;
|
||||||
|
|
||||||
public function getListeners()
|
protected $listeners = [
|
||||||
{
|
|
||||||
$teamId = auth()->user()->currentTeam()->id;
|
|
||||||
|
|
||||||
return [
|
|
||||||
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
|
|
||||||
"echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh',
|
|
||||||
'buildPackUpdated' => '$refresh',
|
'buildPackUpdated' => '$refresh',
|
||||||
'refresh' => '$refresh',
|
'refresh' => '$refresh',
|
||||||
];
|
];
|
||||||
}
|
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
|
|
@ -35,7 +28,7 @@ public function mount()
|
||||||
|
|
||||||
$project = currentTeam()
|
$project = currentTeam()
|
||||||
->projects()
|
->projects()
|
||||||
->select('id', 'uuid', 'team_id')
|
->select('id', 'uuid', 'name', 'team_id')
|
||||||
->where('uuid', request()->route('project_uuid'))
|
->where('uuid', request()->route('project_uuid'))
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
$environment = $project->environments()
|
$environment = $project->environments()
|
||||||
|
|
@ -51,8 +44,6 @@ public function mount()
|
||||||
$this->environment = $environment;
|
$this->environment = $environment;
|
||||||
$this->application = $application;
|
$this->application = $application;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') {
|
if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') {
|
||||||
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
|
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Actions\Application\GenerateConfig;
|
use App\Actions\Application\GenerateConfig;
|
||||||
use App\Jobs\ApplicationDeploymentJob;
|
use App\Jobs\ApplicationDeploymentJob;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use App\Rules\ValidGitBranch;
|
||||||
use App\Support\ValidationPatterns;
|
use App\Support\ValidationPatterns;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
|
@ -144,7 +145,7 @@ protected function rules(): array
|
||||||
'description' => ValidationPatterns::descriptionRules(),
|
'description' => ValidationPatterns::descriptionRules(),
|
||||||
'fqdn' => 'nullable',
|
'fqdn' => 'nullable',
|
||||||
'gitRepository' => 'required',
|
'gitRepository' => 'required',
|
||||||
'gitBranch' => 'required',
|
'gitBranch' => ['required', 'string', new ValidGitBranch],
|
||||||
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
|
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
|
||||||
'installCommand' => ValidationPatterns::shellSafeCommandRules(),
|
'installCommand' => ValidationPatterns::shellSafeCommandRules(),
|
||||||
'buildCommand' => ValidationPatterns::shellSafeCommandRules(),
|
'buildCommand' => ValidationPatterns::shellSafeCommandRules(),
|
||||||
|
|
@ -153,12 +154,12 @@ protected function rules(): array
|
||||||
'staticImage' => 'required',
|
'staticImage' => 'required',
|
||||||
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
|
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
|
||||||
'publishDirectory' => ValidationPatterns::directoryPathRules(),
|
'publishDirectory' => ValidationPatterns::directoryPathRules(),
|
||||||
'portsExposes' => ['required', 'string', 'regex:/^(\d+)(,\d+)*$/'],
|
'portsExposes' => ['nullable', 'string', 'regex:/^(\d+)(,\d+)*$/'],
|
||||||
'portsMappings' => ValidationPatterns::portMappingRules(),
|
'portsMappings' => ValidationPatterns::portMappingRules(),
|
||||||
'customNetworkAliases' => 'nullable',
|
'customNetworkAliases' => 'nullable',
|
||||||
'dockerfile' => 'nullable',
|
'dockerfile' => 'nullable',
|
||||||
'dockerRegistryImageName' => 'nullable',
|
'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(),
|
||||||
'dockerRegistryImageTag' => 'nullable',
|
'dockerRegistryImageTag' => ValidationPatterns::dockerImageTagRules(),
|
||||||
'dockerfileLocation' => ValidationPatterns::filePathRules(),
|
'dockerfileLocation' => ValidationPatterns::filePathRules(),
|
||||||
'dockerComposeLocation' => ValidationPatterns::filePathRules(),
|
'dockerComposeLocation' => ValidationPatterns::filePathRules(),
|
||||||
'dockerCompose' => 'nullable',
|
'dockerCompose' => 'nullable',
|
||||||
|
|
@ -211,7 +212,6 @@ protected function messages(): array
|
||||||
'buildPack.required' => 'The Build Pack field is required.',
|
'buildPack.required' => 'The Build Pack field is required.',
|
||||||
'staticImage.required' => 'The Static Image field is required.',
|
'staticImage.required' => 'The Static Image field is required.',
|
||||||
'baseDirectory.required' => 'The Base Directory field is required.',
|
'baseDirectory.required' => 'The Base Directory field is required.',
|
||||||
'portsExposes.required' => 'The Exposed Ports field is required.',
|
|
||||||
'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).',
|
'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).',
|
||||||
...ValidationPatterns::portMappingMessages(),
|
...ValidationPatterns::portMappingMessages(),
|
||||||
'isStatic.required' => 'The Static setting is required.',
|
'isStatic.required' => 'The Static setting is required.',
|
||||||
|
|
@ -759,7 +759,7 @@ public function submit($showToaster = true)
|
||||||
|
|
||||||
$this->resetErrorBag();
|
$this->resetErrorBag();
|
||||||
|
|
||||||
$this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString();
|
$this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString() ?: null;
|
||||||
if ($this->portsMappings) {
|
if ($this->portsMappings) {
|
||||||
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
|
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
|
||||||
}
|
}
|
||||||
|
|
@ -848,7 +848,7 @@ public function submit($showToaster = true)
|
||||||
}
|
}
|
||||||
if ($this->buildPack === 'dockerimage') {
|
if ($this->buildPack === 'dockerimage') {
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'dockerRegistryImageName' => 'required',
|
'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(required: true),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
41
app/Livewire/Project/Application/ServerStatusBadge.php
Normal file
41
app/Livewire/Project/Application/ServerStatusBadge.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Project\Application;
|
||||||
|
|
||||||
|
use App\Models\Application;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class ServerStatusBadge extends Component
|
||||||
|
{
|
||||||
|
public Application $application;
|
||||||
|
|
||||||
|
public function getListeners(): array
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
if (! $user) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$team = $user->currentTeam();
|
||||||
|
if (! $team) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"echo-private:team.{$team->id},ServiceStatusChanged" => 'refreshStatus',
|
||||||
|
"echo-private:team.{$team->id},ServiceChecked" => 'refreshStatus',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshStatus(): void
|
||||||
|
{
|
||||||
|
$this->application->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.project.application.server-status-badge');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,10 @@
|
||||||
namespace App\Livewire\Project\Application;
|
namespace App\Livewire\Project\Application;
|
||||||
|
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use App\Models\GithubApp;
|
||||||
|
use App\Models\GitlabApp;
|
||||||
use App\Models\PrivateKey;
|
use App\Models\PrivateKey;
|
||||||
|
use App\Rules\ValidGitBranch;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
|
|
@ -21,13 +24,13 @@ class Source extends Component
|
||||||
#[Validate(['nullable', 'string'])]
|
#[Validate(['nullable', 'string'])]
|
||||||
public ?string $privateKeyName = null;
|
public ?string $privateKeyName = null;
|
||||||
|
|
||||||
#[Validate(['nullable', 'integer'])]
|
#[Locked]
|
||||||
public ?int $privateKeyId = null;
|
public ?int $privateKeyId = null;
|
||||||
|
|
||||||
#[Validate(['required', 'string'])]
|
#[Validate(['required', 'string'])]
|
||||||
public string $gitRepository;
|
public string $gitRepository;
|
||||||
|
|
||||||
#[Validate(['required', 'string'])]
|
#[Validate(['required', 'string', new ValidGitBranch])]
|
||||||
public string $gitBranch;
|
public string $gitBranch;
|
||||||
|
|
||||||
#[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
|
#[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
|
||||||
|
|
@ -103,7 +106,8 @@ public function setPrivateKey(int $privateKeyId)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->application);
|
$this->authorize('update', $this->application);
|
||||||
$this->privateKeyId = $privateKeyId;
|
$key = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId);
|
||||||
|
$this->privateKeyId = $key->id;
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
$this->getPrivateKeys();
|
$this->getPrivateKeys();
|
||||||
$this->application->refresh();
|
$this->application->refresh();
|
||||||
|
|
@ -136,8 +140,11 @@ public function changeSource($sourceId, $sourceType)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->application);
|
$this->authorize('update', $this->application);
|
||||||
|
$allowedSourceTypes = [GithubApp::class, GitlabApp::class];
|
||||||
|
abort_unless(in_array($sourceType, $allowedSourceTypes, true), 404);
|
||||||
|
$source = $sourceType::ownedByCurrentTeam()->findOrFail($sourceId);
|
||||||
$this->application->update([
|
$this->application->update([
|
||||||
'source_id' => $sourceId,
|
'source_id' => $source->id,
|
||||||
'source_type' => $sourceType,
|
'source_type' => $sourceType,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Livewire\Project\Database;
|
namespace App\Livewire\Project\Database;
|
||||||
|
|
||||||
use App\Models\ScheduledDatabaseBackup;
|
use App\Models\ScheduledDatabaseBackup;
|
||||||
|
use App\Models\ServiceDatabase;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
|
|
@ -144,7 +145,7 @@ public function delete($password, $selectedActions = [])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$server = null;
|
$server = null;
|
||||||
if ($this->backup->database instanceof \App\Models\ServiceDatabase) {
|
if ($this->backup->database instanceof ServiceDatabase) {
|
||||||
$server = $this->backup->database->service->destination->server;
|
$server = $this->backup->database->service->destination->server;
|
||||||
} elseif ($this->backup->database->destination && $this->backup->database->destination->server) {
|
} elseif ($this->backup->database->destination && $this->backup->database->destination->server) {
|
||||||
$server = $this->backup->database->destination->server;
|
$server = $this->backup->database->destination->server;
|
||||||
|
|
@ -170,7 +171,7 @@ public function delete($password, $selectedActions = [])
|
||||||
|
|
||||||
$this->backup->delete();
|
$this->backup->delete();
|
||||||
|
|
||||||
if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
|
||||||
$serviceDatabase = $this->backup->database;
|
$serviceDatabase = $this->backup->database;
|
||||||
|
|
||||||
return redirect()->route('project.service.database.backups', [
|
return redirect()->route('project.service.database.backups', [
|
||||||
|
|
@ -182,7 +183,7 @@ public function delete($password, $selectedActions = [])
|
||||||
} else {
|
} else {
|
||||||
return redirect()->route('project.database.backup.index', $this->parameters);
|
return redirect()->route('project.database.backup.index', $this->parameters);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
|
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
|
||||||
|
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
|
|
@ -207,6 +208,13 @@ private function customValidate()
|
||||||
$this->backup->s3_storage_id = null;
|
$this->backup->s3_storage_id = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// S3 backup cannot be enabled without a valid S3 storage owned by the team
|
||||||
|
$availableS3Ids = collect($this->s3s)->pluck('id');
|
||||||
|
if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) {
|
||||||
|
$this->backup->save_s3 = $this->saveS3 = false;
|
||||||
|
$this->backup->s3_storage_id = $this->s3StorageId = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate that disable_local_backup can only be true when S3 backup is enabled
|
// Validate that disable_local_backup can only be true when S3 backup is enabled
|
||||||
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
|
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
|
||||||
$this->backup->disable_local_backup = $this->disableLocalBackup = false;
|
$this->backup->disable_local_backup = $this->disableLocalBackup = false;
|
||||||
|
|
@ -214,7 +222,7 @@ private function customValidate()
|
||||||
|
|
||||||
$isValid = validate_cron_expression($this->backup->frequency);
|
$isValid = validate_cron_expression($this->backup->frequency);
|
||||||
if (! $isValid) {
|
if (! $isValid) {
|
||||||
throw new \Exception('Invalid Cron / Human expression');
|
throw new Exception('Invalid Cron / Human expression');
|
||||||
}
|
}
|
||||||
$this->validate();
|
$this->validate();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,18 +40,21 @@ class General extends Component
|
||||||
|
|
||||||
public ?string $customDockerRunOptions = null;
|
public ?string $customDockerRunOptions = null;
|
||||||
|
|
||||||
public ?string $dbUrl = null;
|
|
||||||
|
|
||||||
public ?string $dbUrlPublic = null;
|
|
||||||
|
|
||||||
public bool $isLogDrainEnabled = false;
|
public bool $isLogDrainEnabled = false;
|
||||||
|
|
||||||
public function getListeners()
|
public function getListeners(): array
|
||||||
{
|
{
|
||||||
$teamId = Auth::user()->currentTeam()->id;
|
$user = Auth::user();
|
||||||
|
if (! $user) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$team = $user->currentTeam();
|
||||||
|
if (! $team) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
|
"echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,8 +91,6 @@ protected function rules(): array
|
||||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||||
'customDockerRunOptions' => 'nullable|string',
|
'customDockerRunOptions' => 'nullable|string',
|
||||||
'dbUrl' => 'nullable|string',
|
|
||||||
'dbUrlPublic' => 'nullable|string',
|
|
||||||
'isLogDrainEnabled' => 'nullable|boolean',
|
'isLogDrainEnabled' => 'nullable|boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -129,9 +130,6 @@ public function syncData(bool $toModel = false)
|
||||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||||
$this->database->save();
|
$this->database->save();
|
||||||
|
|
||||||
$this->dbUrl = $this->database->internal_db_url;
|
|
||||||
$this->dbUrlPublic = $this->database->external_db_url;
|
|
||||||
} else {
|
} else {
|
||||||
$this->name = $this->database->name;
|
$this->name = $this->database->name;
|
||||||
$this->description = $this->database->description;
|
$this->description = $this->database->description;
|
||||||
|
|
@ -144,8 +142,6 @@ public function syncData(bool $toModel = false)
|
||||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||||
$this->dbUrl = $this->database->internal_db_url;
|
|
||||||
$this->dbUrlPublic = $this->database->external_db_url;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,6 +190,7 @@ public function instantSave()
|
||||||
StopDatabaseProxy::run($this->database);
|
StopDatabaseProxy::run($this->database);
|
||||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||||
}
|
}
|
||||||
|
$this->dispatch('databaseUpdated');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->isPublic = ! $this->isPublic;
|
$this->isPublic = ! $this->isPublic;
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
|
|
@ -202,9 +199,13 @@ public function instantSave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function databaseProxyStopped()
|
public function databaseProxyStopped(): void
|
||||||
{
|
{
|
||||||
$this->syncData();
|
$this->database->refresh();
|
||||||
|
$this->isPublic = $this->database->is_public;
|
||||||
|
$this->publicPort = $this->database->public_port;
|
||||||
|
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||||
|
$this->dispatch('databaseUpdated');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
|
|
@ -220,6 +221,7 @@ public function submit()
|
||||||
}
|
}
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
$this->dispatch('success', 'Database updated.');
|
$this->dispatch('success', 'Database updated.');
|
||||||
|
$this->dispatch('databaseUpdated');
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
31
app/Livewire/Project/Database/Clickhouse/StatusInfo.php
Normal file
31
app/Livewire/Project/Database/Clickhouse/StatusInfo.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Project\Database\Clickhouse;
|
||||||
|
|
||||||
|
use App\Models\StandaloneClickhouse;
|
||||||
|
use App\Traits\HasDatabaseStatusInfo;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class StatusInfo extends Component
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
use HasDatabaseStatusInfo;
|
||||||
|
|
||||||
|
public StandaloneClickhouse $database;
|
||||||
|
|
||||||
|
protected function databaseLabel(): string
|
||||||
|
{
|
||||||
|
return 'Clickhouse';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function supportsSsl(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function showPublicUrlPlaceholder(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
namespace App\Livewire\Project\Database;
|
namespace App\Livewire\Project\Database;
|
||||||
|
|
||||||
use Auth;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Support\ItemNotFoundException;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Configuration extends Component
|
class Configuration extends Component
|
||||||
|
|
@ -18,15 +19,6 @@ class Configuration extends Component
|
||||||
|
|
||||||
public $environment;
|
public $environment;
|
||||||
|
|
||||||
public function getListeners()
|
|
||||||
{
|
|
||||||
$teamId = Auth::user()->currentTeam()->id;
|
|
||||||
|
|
||||||
return [
|
|
||||||
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
@ -34,7 +26,7 @@ public function mount()
|
||||||
|
|
||||||
$project = currentTeam()
|
$project = currentTeam()
|
||||||
->projects()
|
->projects()
|
||||||
->select('id', 'uuid', 'team_id')
|
->select('id', 'uuid', 'name', 'team_id')
|
||||||
->where('uuid', request()->route('project_uuid'))
|
->where('uuid', request()->route('project_uuid'))
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
$environment = $project->environments()
|
$environment = $project->environments()
|
||||||
|
|
@ -55,10 +47,10 @@ public function mount()
|
||||||
$this->dispatch('configurationChanged');
|
$this->dispatch('configurationChanged');
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
|
if ($e instanceof AuthorizationException) {
|
||||||
return redirect()->route('dashboard');
|
return redirect()->route('dashboard');
|
||||||
}
|
}
|
||||||
if ($e instanceof \Illuminate\Support\ItemNotFoundException) {
|
if ($e instanceof ItemNotFoundException) {
|
||||||
return redirect()->route('dashboard');
|
return redirect()->route('dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
namespace App\Livewire\Project\Database;
|
namespace App\Livewire\Project\Database;
|
||||||
|
|
||||||
|
use App\Models\S3Storage;
|
||||||
use App\Models\ScheduledDatabaseBackup;
|
use App\Models\ScheduledDatabaseBackup;
|
||||||
|
use App\Models\ServiceDatabase;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
|
|
@ -48,6 +50,20 @@ public function submit()
|
||||||
|
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
|
if ($this->saveToS3) {
|
||||||
|
$s3StorageExists = ! is_null($this->s3StorageId)
|
||||||
|
&& S3Storage::where('team_id', currentTeam()->id)
|
||||||
|
->where('is_usable', true)
|
||||||
|
->whereKey($this->s3StorageId)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $s3StorageExists) {
|
||||||
|
$this->dispatch('error', 'Please select a valid S3 storage to enable S3 backups.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$isValid = validate_cron_expression($this->frequency);
|
$isValid = validate_cron_expression($this->frequency);
|
||||||
if (! $isValid) {
|
if (! $isValid) {
|
||||||
$this->dispatch('error', 'Invalid Cron / Human expression.');
|
$this->dispatch('error', 'Invalid Cron / Human expression.');
|
||||||
|
|
@ -74,7 +90,7 @@ public function submit()
|
||||||
}
|
}
|
||||||
|
|
||||||
$databaseBackup = ScheduledDatabaseBackup::create($payload);
|
$databaseBackup = ScheduledDatabaseBackup::create($payload);
|
||||||
if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
if ($this->database->getMorphClass() === ServiceDatabase::class) {
|
||||||
$this->dispatch('refreshScheduledBackups', $databaseBackup->id);
|
$this->dispatch('refreshScheduledBackups', $databaseBackup->id);
|
||||||
} else {
|
} else {
|
||||||
$this->dispatch('refreshScheduledBackups');
|
$this->dispatch('refreshScheduledBackups');
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,9 @@
|
||||||
|
|
||||||
use App\Actions\Database\StartDatabaseProxy;
|
use App\Actions\Database\StartDatabaseProxy;
|
||||||
use App\Actions\Database\StopDatabaseProxy;
|
use App\Actions\Database\StopDatabaseProxy;
|
||||||
use App\Helpers\SslHelper;
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\StandaloneDragonfly;
|
use App\Models\StandaloneDragonfly;
|
||||||
use App\Support\ValidationPatterns;
|
use App\Support\ValidationPatterns;
|
||||||
use Carbon\Carbon;
|
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
@ -40,25 +38,21 @@ class General extends Component
|
||||||
|
|
||||||
public ?string $customDockerRunOptions = null;
|
public ?string $customDockerRunOptions = null;
|
||||||
|
|
||||||
public ?string $dbUrl = null;
|
|
||||||
|
|
||||||
public ?string $dbUrlPublic = null;
|
|
||||||
|
|
||||||
public bool $isLogDrainEnabled = false;
|
public bool $isLogDrainEnabled = false;
|
||||||
|
|
||||||
public ?Carbon $certificateValidUntil = null;
|
public function getListeners(): array
|
||||||
|
|
||||||
public bool $enable_ssl = false;
|
|
||||||
|
|
||||||
public function getListeners()
|
|
||||||
{
|
{
|
||||||
$userId = Auth::id();
|
$user = Auth::user();
|
||||||
$teamId = Auth::user()->currentTeam()->id;
|
if (! $user) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$team = $user->currentTeam();
|
||||||
|
if (! $team) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
|
"echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
|
||||||
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
|
|
||||||
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,12 +67,6 @@ public function mount()
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$existingCert = $this->database->sslCertificates()->first();
|
|
||||||
|
|
||||||
if ($existingCert) {
|
|
||||||
$this->certificateValidUntil = $existingCert->valid_until;
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
@ -98,10 +86,7 @@ protected function rules(): array
|
||||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||||
'customDockerRunOptions' => 'nullable|string',
|
'customDockerRunOptions' => 'nullable|string',
|
||||||
'dbUrl' => 'nullable|string',
|
|
||||||
'dbUrlPublic' => 'nullable|string',
|
|
||||||
'isLogDrainEnabled' => 'nullable|boolean',
|
'isLogDrainEnabled' => 'nullable|boolean',
|
||||||
'enable_ssl' => 'nullable|boolean',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,11 +122,7 @@ public function syncData(bool $toModel = false)
|
||||||
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
||||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||||
$this->database->enable_ssl = $this->enable_ssl;
|
|
||||||
$this->database->save();
|
$this->database->save();
|
||||||
|
|
||||||
$this->dbUrl = $this->database->internal_db_url;
|
|
||||||
$this->dbUrlPublic = $this->database->external_db_url;
|
|
||||||
} else {
|
} else {
|
||||||
$this->name = $this->database->name;
|
$this->name = $this->database->name;
|
||||||
$this->description = $this->database->description;
|
$this->description = $this->database->description;
|
||||||
|
|
@ -153,9 +134,6 @@ public function syncData(bool $toModel = false)
|
||||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||||
$this->enable_ssl = $this->database->enable_ssl;
|
|
||||||
$this->dbUrl = $this->database->internal_db_url;
|
|
||||||
$this->dbUrlPublic = $this->database->external_db_url;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,6 +182,7 @@ public function instantSave()
|
||||||
StopDatabaseProxy::run($this->database);
|
StopDatabaseProxy::run($this->database);
|
||||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||||
}
|
}
|
||||||
|
$this->dispatch('databaseUpdated');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->isPublic = ! $this->isPublic;
|
$this->isPublic = ! $this->isPublic;
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
|
|
@ -212,9 +191,13 @@ public function instantSave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function databaseProxyStopped()
|
public function databaseProxyStopped(): void
|
||||||
{
|
{
|
||||||
$this->syncData();
|
$this->database->refresh();
|
||||||
|
$this->isPublic = $this->database->is_public;
|
||||||
|
$this->publicPort = $this->database->public_port;
|
||||||
|
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||||
|
$this->dispatch('databaseUpdated');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
|
|
@ -230,6 +213,7 @@ public function submit()
|
||||||
}
|
}
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
$this->dispatch('success', 'Database updated.');
|
$this->dispatch('success', 'Database updated.');
|
||||||
|
$this->dispatch('databaseUpdated');
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -241,67 +225,6 @@ public function submit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function instantSaveSSL()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->authorize('update', $this->database);
|
|
||||||
|
|
||||||
$this->syncData(true);
|
|
||||||
$this->dispatch('success', 'SSL configuration updated.');
|
|
||||||
} catch (Exception $e) {
|
|
||||||
return handleError($e, $this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function regenerateSslCertificate()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->authorize('update', $this->database);
|
|
||||||
|
|
||||||
$existingCert = $this->database->sslCertificates()->first();
|
|
||||||
|
|
||||||
if (! $existingCert) {
|
|
||||||
$this->dispatch('error', 'No existing SSL certificate found for this database.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$server = $this->database->destination->server;
|
|
||||||
|
|
||||||
$caCert = $server->sslCertificates()
|
|
||||||
->where('is_ca_certificate', true)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $caCert) {
|
|
||||||
$server->generateCaCertificate();
|
|
||||||
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $caCert) {
|
|
||||||
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SslHelper::generateSslCertificate(
|
|
||||||
commonName: $existingCert->common_name,
|
|
||||||
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
|
|
||||||
resourceType: $existingCert->resource_type,
|
|
||||||
resourceId: $existingCert->resource_id,
|
|
||||||
serverId: $existingCert->server_id,
|
|
||||||
caCert: $caCert->ssl_certificate,
|
|
||||||
caKey: $caCert->ssl_private_key,
|
|
||||||
configurationDir: $existingCert->configuration_dir,
|
|
||||||
mountPath: $existingCert->mount_path,
|
|
||||||
isPemKeyFileRequired: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
|
|
||||||
} catch (Exception $e) {
|
|
||||||
handleError($e, $this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function refresh(): void
|
public function refresh(): void
|
||||||
{
|
{
|
||||||
$this->database->refresh();
|
$this->database->refresh();
|
||||||
|
|
|
||||||
26
app/Livewire/Project/Database/Dragonfly/StatusInfo.php
Normal file
26
app/Livewire/Project/Database/Dragonfly/StatusInfo.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Project\Database\Dragonfly;
|
||||||
|
|
||||||
|
use App\Models\StandaloneDragonfly;
|
||||||
|
use App\Traits\HasDatabaseStatusInfo;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class StatusInfo extends Component
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
use HasDatabaseStatusInfo;
|
||||||
|
|
||||||
|
public StandaloneDragonfly $database;
|
||||||
|
|
||||||
|
protected function databaseLabel(): string
|
||||||
|
{
|
||||||
|
return 'Dragonfly';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function showPublicUrlPlaceholder(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
117
app/Livewire/Project/Database/Health.php
Normal file
117
app/Livewire/Project/Database/Health.php
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Project\Database;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Health extends Component
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
public $database;
|
||||||
|
|
||||||
|
#[Validate(['boolean'])]
|
||||||
|
public bool $healthCheckEnabled = true;
|
||||||
|
|
||||||
|
#[Validate(['integer', 'min:1'])]
|
||||||
|
public int $healthCheckInterval = 15;
|
||||||
|
|
||||||
|
#[Validate(['integer', 'min:1'])]
|
||||||
|
public int $healthCheckTimeout = 5;
|
||||||
|
|
||||||
|
#[Validate(['integer', 'min:1'])]
|
||||||
|
public int $healthCheckRetries = 5;
|
||||||
|
|
||||||
|
#[Validate(['integer', 'min:0'])]
|
||||||
|
public int $healthCheckStartPeriod = 5;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->authorize('view', $this->database);
|
||||||
|
$this->syncData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncData(bool $toModel = false): void
|
||||||
|
{
|
||||||
|
if ($toModel) {
|
||||||
|
$this->validate();
|
||||||
|
$this->database->health_check_enabled = $this->healthCheckEnabled;
|
||||||
|
$this->database->health_check_interval = $this->healthCheckInterval;
|
||||||
|
$this->database->health_check_timeout = $this->healthCheckTimeout;
|
||||||
|
$this->database->health_check_retries = $this->healthCheckRetries;
|
||||||
|
$this->database->health_check_start_period = $this->healthCheckStartPeriod;
|
||||||
|
$this->database->save();
|
||||||
|
} else {
|
||||||
|
$this->healthCheckEnabled = $this->database->health_check_enabled;
|
||||||
|
$this->healthCheckInterval = $this->database->health_check_interval;
|
||||||
|
$this->healthCheckTimeout = $this->database->health_check_timeout;
|
||||||
|
$this->healthCheckRetries = $this->database->health_check_retries;
|
||||||
|
$this->healthCheckStartPeriod = $this->database->health_check_start_period;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function instantSave(): void
|
||||||
|
{
|
||||||
|
$this->submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submit(): void
|
||||||
|
{
|
||||||
|
$updateSuccessful = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->authorize('update', $this->database);
|
||||||
|
$this->syncData(true);
|
||||||
|
$updateSuccessful = true;
|
||||||
|
$this->dispatch('success', 'Health check updated. Restart the database to apply the changes.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
handleError($e, $this);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $updateSuccessful) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->markConfigurationChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleHealthcheck(): void
|
||||||
|
{
|
||||||
|
$updateSuccessful = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->authorize('update', $this->database);
|
||||||
|
$this->healthCheckEnabled = ! $this->healthCheckEnabled;
|
||||||
|
$this->syncData(true);
|
||||||
|
$updateSuccessful = true;
|
||||||
|
$this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'. Restart the database to apply the changes.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
handleError($e, $this);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $updateSuccessful) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->markConfigurationChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markConfigurationChanged(): void
|
||||||
|
{
|
||||||
|
if (is_null($this->database->config_hash)) {
|
||||||
|
$this->database->isConfigurationChanged(true);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->dispatch('configurationChanged');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.project.database.health');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,14 +2,14 @@
|
||||||
|
|
||||||
namespace App\Livewire\Project\Database;
|
namespace App\Livewire\Project\Database;
|
||||||
|
|
||||||
use App\Models\S3Storage;
|
use App\Models\ServiceDatabase;
|
||||||
use App\Models\Server;
|
use App\Models\StandaloneClickhouse;
|
||||||
use App\Models\Service;
|
use App\Models\StandaloneDragonfly;
|
||||||
use App\Support\ValidationPatterns;
|
use App\Models\StandaloneKeydb;
|
||||||
|
use App\Models\StandaloneRedis;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Livewire\Attributes\Computed;
|
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
|
@ -17,797 +17,134 @@ class Import extends Component
|
||||||
{
|
{
|
||||||
use AuthorizesRequests;
|
use AuthorizesRequests;
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that a string is safe for use as an S3 bucket name.
|
|
||||||
* Allows alphanumerics, dots, dashes, and underscores.
|
|
||||||
*/
|
|
||||||
private function validateBucketName(string $bucket): bool
|
|
||||||
{
|
|
||||||
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that a string is safe for use as an S3 path.
|
|
||||||
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
|
|
||||||
*/
|
|
||||||
private function validateS3Path(string $path): bool
|
|
||||||
{
|
|
||||||
// Must not be empty
|
|
||||||
if (empty($path)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must not contain dangerous shell metacharacters or command injection patterns
|
|
||||||
$dangerousPatterns = [
|
|
||||||
'..', // Directory traversal
|
|
||||||
'$(', // Command substitution
|
|
||||||
'`', // Backtick command substitution
|
|
||||||
'|', // Pipe
|
|
||||||
';', // Command separator
|
|
||||||
'&', // Background/AND
|
|
||||||
'>', // Redirect
|
|
||||||
'<', // Redirect
|
|
||||||
"\n", // Newline
|
|
||||||
"\r", // Carriage return
|
|
||||||
"\0", // Null byte
|
|
||||||
"'", // Single quote
|
|
||||||
'"', // Double quote
|
|
||||||
'\\', // Backslash
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($dangerousPatterns as $pattern) {
|
|
||||||
if (str_contains($path, $pattern)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
|
|
||||||
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that a string is safe for use as a file path on the server.
|
|
||||||
*/
|
|
||||||
private function validateServerPath(string $path): bool
|
|
||||||
{
|
|
||||||
// Must be an absolute path
|
|
||||||
if (! str_starts_with($path, '/')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must not contain dangerous shell metacharacters or command injection patterns
|
|
||||||
$dangerousPatterns = [
|
|
||||||
'..', // Directory traversal
|
|
||||||
'$(', // Command substitution
|
|
||||||
'`', // Backtick command substitution
|
|
||||||
'|', // Pipe
|
|
||||||
';', // Command separator
|
|
||||||
'&', // Background/AND
|
|
||||||
'>', // Redirect
|
|
||||||
'<', // Redirect
|
|
||||||
"\n", // Newline
|
|
||||||
"\r", // Carriage return
|
|
||||||
"\0", // Null byte
|
|
||||||
"'", // Single quote
|
|
||||||
'"', // Double quote
|
|
||||||
'\\', // Backslash
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($dangerousPatterns as $pattern) {
|
|
||||||
if (str_contains($path, $pattern)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
|
|
||||||
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool $unsupported = false;
|
|
||||||
|
|
||||||
// Store IDs instead of models for proper Livewire serialization
|
|
||||||
#[Locked]
|
#[Locked]
|
||||||
public ?int $resourceId = null;
|
public ?int $resourceId = null;
|
||||||
|
|
||||||
#[Locked]
|
#[Locked]
|
||||||
public ?string $resourceType = null;
|
public ?string $resourceType = null;
|
||||||
|
|
||||||
#[Locked]
|
|
||||||
public ?int $serverId = null;
|
|
||||||
|
|
||||||
// View-friendly properties to avoid computed property access in Blade
|
|
||||||
#[Locked]
|
|
||||||
public string $resourceUuid = '';
|
|
||||||
|
|
||||||
public string $resourceStatus = '';
|
public string $resourceStatus = '';
|
||||||
|
|
||||||
#[Locked]
|
public string $resourceUuid = '';
|
||||||
public string $resourceDbType = '';
|
|
||||||
|
|
||||||
public array $parameters = [];
|
public bool $unsupported = false;
|
||||||
|
|
||||||
public array $containers = [];
|
public function getListeners(): array
|
||||||
|
|
||||||
public bool $scpInProgress = false;
|
|
||||||
|
|
||||||
public bool $importRunning = false;
|
|
||||||
|
|
||||||
public ?string $filename = null;
|
|
||||||
|
|
||||||
public ?string $filesize = null;
|
|
||||||
|
|
||||||
public bool $isUploading = false;
|
|
||||||
|
|
||||||
public int $progress = 0;
|
|
||||||
|
|
||||||
public bool $error = false;
|
|
||||||
|
|
||||||
#[Locked]
|
|
||||||
public string $container;
|
|
||||||
|
|
||||||
public array $importCommands = [];
|
|
||||||
|
|
||||||
public bool $dumpAll = false;
|
|
||||||
|
|
||||||
public string $restoreCommandText = '';
|
|
||||||
|
|
||||||
public string $customLocation = '';
|
|
||||||
|
|
||||||
public ?int $activityId = null;
|
|
||||||
|
|
||||||
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
|
||||||
|
|
||||||
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
|
||||||
|
|
||||||
public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
|
|
||||||
|
|
||||||
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
|
|
||||||
|
|
||||||
// S3 Restore properties
|
|
||||||
public array $availableS3Storages = [];
|
|
||||||
|
|
||||||
public ?int $s3StorageId = null;
|
|
||||||
|
|
||||||
public string $s3Path = '';
|
|
||||||
|
|
||||||
public ?int $s3FileSize = null;
|
|
||||||
|
|
||||||
#[Computed]
|
|
||||||
public function resource()
|
|
||||||
{
|
{
|
||||||
if ($this->resourceId === null || $this->resourceType === null) {
|
$listeners = ['databaseUpdated' => 'refreshStatus'];
|
||||||
return null;
|
|
||||||
|
$user = Auth::user();
|
||||||
|
if (! $user) {
|
||||||
|
return $listeners;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resourceType::find($this->resourceId);
|
$listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refreshStatus';
|
||||||
|
|
||||||
|
$team = $user->currentTeam();
|
||||||
|
if ($team) {
|
||||||
|
$listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refreshStatus';
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
return $listeners;
|
||||||
public function server()
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
{
|
{
|
||||||
if ($this->serverId === null) {
|
$resource = $this->resolveResourceFromRoute();
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Server::ownedByCurrentTeam()->find($this->serverId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getListeners()
|
|
||||||
{
|
|
||||||
$userId = Auth::id();
|
|
||||||
|
|
||||||
return [
|
|
||||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
|
||||||
'slideOverClosed' => 'resetActivityId',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetActivityId()
|
|
||||||
{
|
|
||||||
$this->activityId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount()
|
|
||||||
{
|
|
||||||
$this->parameters = get_route_parameters();
|
|
||||||
$this->getContainers();
|
|
||||||
$this->loadAvailableS3Storages();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedDumpAll($value)
|
|
||||||
{
|
|
||||||
$morphClass = $this->resource->getMorphClass();
|
|
||||||
|
|
||||||
// Handle ServiceDatabase by checking the database type
|
|
||||||
if ($morphClass === \App\Models\ServiceDatabase::class) {
|
|
||||||
$dbType = $this->resource->databaseType();
|
|
||||||
if (str_contains($dbType, 'mysql')) {
|
|
||||||
$morphClass = 'mysql';
|
|
||||||
} elseif (str_contains($dbType, 'mariadb')) {
|
|
||||||
$morphClass = 'mariadb';
|
|
||||||
} elseif (str_contains($dbType, 'postgres')) {
|
|
||||||
$morphClass = 'postgresql';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch ($morphClass) {
|
|
||||||
case \App\Models\StandaloneMariadb::class:
|
|
||||||
case 'mariadb':
|
|
||||||
if ($value === true) {
|
|
||||||
$this->mariadbRestoreCommand = <<<'EOD'
|
|
||||||
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
|
|
||||||
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
|
|
||||||
done && \
|
|
||||||
mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
|
|
||||||
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
|
|
||||||
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
|
|
||||||
EOD;
|
|
||||||
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
|
|
||||||
} else {
|
|
||||||
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case \App\Models\StandaloneMysql::class:
|
|
||||||
case 'mysql':
|
|
||||||
if ($value === true) {
|
|
||||||
$this->mysqlRestoreCommand = <<<'EOD'
|
|
||||||
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
|
|
||||||
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
|
|
||||||
done && \
|
|
||||||
mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
|
|
||||||
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
|
|
||||||
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
|
|
||||||
EOD;
|
|
||||||
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
|
|
||||||
} else {
|
|
||||||
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case \App\Models\StandalonePostgresql::class:
|
|
||||||
case 'postgresql':
|
|
||||||
if ($value === true) {
|
|
||||||
$this->postgresqlRestoreCommand = <<<'EOD'
|
|
||||||
psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
|
|
||||||
psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
|
|
||||||
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
|
|
||||||
EOD;
|
|
||||||
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
|
||||||
} else {
|
|
||||||
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getContainers()
|
|
||||||
{
|
|
||||||
$this->containers = [];
|
|
||||||
$teamId = data_get(auth()->user()->currentTeam(), 'id');
|
|
||||||
|
|
||||||
// Try to find resource by route parameter
|
|
||||||
$databaseUuid = data_get($this->parameters, 'database_uuid');
|
|
||||||
$stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
|
|
||||||
|
|
||||||
$resource = null;
|
|
||||||
if ($databaseUuid) {
|
|
||||||
// Standalone database route
|
|
||||||
$resource = getResourceByUuid($databaseUuid, $teamId);
|
|
||||||
if (is_null($resource)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
} elseif ($stackServiceUuid) {
|
|
||||||
// ServiceDatabase route - look up the service database
|
|
||||||
$serviceUuid = data_get($this->parameters, 'service_uuid');
|
|
||||||
$service = Service::whereUuid($serviceUuid)->first();
|
|
||||||
if (! $service) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
|
|
||||||
if (is_null($resource)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->authorize('view', $resource);
|
$this->authorize('view', $resource);
|
||||||
|
|
||||||
// Store IDs for Livewire serialization
|
|
||||||
$this->resourceId = $resource->id;
|
$this->resourceId = $resource->id;
|
||||||
$this->resourceType = get_class($resource);
|
$this->resourceType = get_class($resource);
|
||||||
|
|
||||||
// Store view-friendly properties
|
$this->refreshStatus();
|
||||||
$this->resourceStatus = $resource->status ?? '';
|
}
|
||||||
|
|
||||||
// Handle ServiceDatabase server access differently
|
public function refreshStatus(): void
|
||||||
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
{
|
||||||
$server = $resource->service?->server;
|
$resource = $this->resolveStoredResource();
|
||||||
if (! $server) {
|
$this->authorize('view', $resource);
|
||||||
abort(404, 'Server not found for this service database.');
|
|
||||||
}
|
|
||||||
$this->serverId = $server->id;
|
|
||||||
$this->container = $resource->name.'-'.$resource->service->uuid;
|
|
||||||
$this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
|
|
||||||
|
|
||||||
// Determine database type for ServiceDatabase
|
$resource->refresh();
|
||||||
$dbType = $resource->databaseType();
|
|
||||||
if (str_contains($dbType, 'postgres')) {
|
|
||||||
$this->resourceDbType = 'standalone-postgresql';
|
|
||||||
} elseif (str_contains($dbType, 'mysql')) {
|
|
||||||
$this->resourceDbType = 'standalone-mysql';
|
|
||||||
} elseif (str_contains($dbType, 'mariadb')) {
|
|
||||||
$this->resourceDbType = 'standalone-mariadb';
|
|
||||||
} elseif (str_contains($dbType, 'mongo')) {
|
|
||||||
$this->resourceDbType = 'standalone-mongodb';
|
|
||||||
} else {
|
|
||||||
$this->resourceDbType = $dbType;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$server = $resource->destination?->server;
|
|
||||||
if (! $server) {
|
|
||||||
abort(404, 'Server not found for this database.');
|
|
||||||
}
|
|
||||||
$this->serverId = $server->id;
|
|
||||||
$this->container = $resource->uuid;
|
|
||||||
$this->resourceUuid = $resource->uuid;
|
$this->resourceUuid = $resource->uuid;
|
||||||
$this->resourceDbType = $resource->type();
|
$this->resourceStatus = $resource->status ?? '';
|
||||||
|
$this->unsupported = $this->isUnsupportedResource($resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str($resource->status)->startsWith('running')) {
|
public function render(): View
|
||||||
$this->containers[] = $this->container;
|
{
|
||||||
|
return view('livewire.project.database.import');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveResourceFromRoute(): object
|
||||||
|
{
|
||||||
|
$parameters = get_route_parameters();
|
||||||
|
$teamId = data_get(Auth::user()?->currentTeam(), 'id');
|
||||||
|
$databaseUuid = data_get($parameters, 'database_uuid');
|
||||||
|
$stackServiceUuid = data_get($parameters, 'stack_service_uuid');
|
||||||
|
|
||||||
|
if ($databaseUuid) {
|
||||||
|
$resource = getResourceByUuid($databaseUuid, $teamId);
|
||||||
|
if ($resource) {
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stackServiceUuid) {
|
||||||
|
$project = currentTeam()
|
||||||
|
->projects()
|
||||||
|
->select('id', 'uuid', 'team_id')
|
||||||
|
->where('uuid', data_get($parameters, 'project_uuid'))
|
||||||
|
->firstOrFail();
|
||||||
|
$environment = $project->environments()
|
||||||
|
->select('id', 'uuid', 'name', 'project_id')
|
||||||
|
->where('uuid', data_get($parameters, 'environment_uuid'))
|
||||||
|
->firstOrFail();
|
||||||
|
$service = $environment->services()->whereUuid(data_get($parameters, 'service_uuid'))->firstOrFail();
|
||||||
|
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
|
||||||
|
if ($resource) {
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveStoredResource(): object
|
||||||
|
{
|
||||||
|
if ($this->resourceId === null || $this->resourceType === null) {
|
||||||
|
return $this->resolveResourceFromRoute();
|
||||||
|
}
|
||||||
|
|
||||||
|
$resource = $this->resourceType::find($this->resourceId);
|
||||||
|
if ($resource) {
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isUnsupportedResource(object $resource): bool
|
||||||
|
{
|
||||||
if (
|
if (
|
||||||
$resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
|
$resource instanceof StandaloneRedis ||
|
||||||
$resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
|
$resource instanceof StandaloneKeydb ||
|
||||||
$resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
|
$resource instanceof StandaloneDragonfly ||
|
||||||
$resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
|
$resource instanceof StandaloneClickhouse
|
||||||
) {
|
) {
|
||||||
$this->unsupported = true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
|
if ($resource instanceof ServiceDatabase) {
|
||||||
if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
|
||||||
$dbType = $resource->databaseType();
|
$dbType = $resource->databaseType();
|
||||||
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
|
|
||||||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
|
return str_contains($dbType, 'redis') ||
|
||||||
$this->unsupported = true;
|
str_contains($dbType, 'keydb') ||
|
||||||
}
|
str_contains($dbType, 'dragonfly') ||
|
||||||
}
|
str_contains($dbType, 'clickhouse');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function checkFile()
|
return false;
|
||||||
{
|
|
||||||
if (filled($this->customLocation)) {
|
|
||||||
// Validate the custom location to prevent command injection
|
|
||||||
if (! $this->validateServerPath($this->customLocation)) {
|
|
||||||
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->server) {
|
|
||||||
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$escapedPath = escapeshellarg($this->customLocation);
|
|
||||||
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
|
|
||||||
if (blank($result)) {
|
|
||||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$this->filename = $this->customLocation;
|
|
||||||
$this->dispatch('success', 'The file exists.');
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return handleError($e, $this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function runImport(string $password = ''): bool|string
|
|
||||||
{
|
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
|
||||||
return 'The provided password is incorrect.';
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->authorize('update', $this->resource);
|
|
||||||
|
|
||||||
if (! ValidationPatterns::isValidContainerName($this->container)) {
|
|
||||||
$this->dispatch('error', 'Invalid container name.');
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->filename === '') {
|
|
||||||
$this->dispatch('error', 'Please select a file to import.');
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->server) {
|
|
||||||
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->importRunning = true;
|
|
||||||
$this->importCommands = [];
|
|
||||||
$backupFileName = "upload/{$this->resourceUuid}/restore";
|
|
||||||
|
|
||||||
// Check if an uploaded file exists first (takes priority over custom location)
|
|
||||||
if (Storage::exists($backupFileName)) {
|
|
||||||
$path = Storage::path($backupFileName);
|
|
||||||
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
|
|
||||||
instant_scp($path, $tmpPath, $this->server);
|
|
||||||
Storage::delete($backupFileName);
|
|
||||||
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
|
|
||||||
} elseif (filled($this->customLocation)) {
|
|
||||||
// Validate the custom location to prevent command injection
|
|
||||||
if (! $this->validateServerPath($this->customLocation)) {
|
|
||||||
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
|
|
||||||
$escapedCustomLocation = escapeshellarg($this->customLocation);
|
|
||||||
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
|
|
||||||
} else {
|
|
||||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy the restore command to a script file
|
|
||||||
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
|
|
||||||
|
|
||||||
$restoreCommand = $this->buildRestoreCommand($tmpPath);
|
|
||||||
|
|
||||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
|
||||||
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
|
||||||
$this->importCommands[] = "chmod +x {$scriptPath}";
|
|
||||||
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
|
|
||||||
|
|
||||||
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
|
|
||||||
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
|
||||||
|
|
||||||
if (! empty($this->importCommands)) {
|
|
||||||
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
|
|
||||||
'scriptPath' => $scriptPath,
|
|
||||||
'tmpPath' => $tmpPath,
|
|
||||||
'container' => $this->container,
|
|
||||||
'serverId' => $this->server->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Track the activity ID
|
|
||||||
$this->activityId = $activity->id;
|
|
||||||
|
|
||||||
// Dispatch activity to the monitor and open slide-over
|
|
||||||
$this->dispatch('activityMonitor', $activity->id);
|
|
||||||
$this->dispatch('databaserestore');
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
handleError($e, $this);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} finally {
|
|
||||||
$this->filename = null;
|
|
||||||
$this->importCommands = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function loadAvailableS3Storages()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
|
|
||||||
->where('is_usable', true)
|
|
||||||
->get()
|
|
||||||
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
|
|
||||||
->toArray();
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->availableS3Storages = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedS3Path($value)
|
|
||||||
{
|
|
||||||
// Reset validation state when path changes
|
|
||||||
$this->s3FileSize = null;
|
|
||||||
|
|
||||||
// Ensure path starts with a slash
|
|
||||||
if ($value !== null && $value !== '') {
|
|
||||||
$this->s3Path = str($value)->trim()->start('/')->value();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedS3StorageId()
|
|
||||||
{
|
|
||||||
// Reset validation state when storage changes
|
|
||||||
$this->s3FileSize = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function checkS3File()
|
|
||||||
{
|
|
||||||
if (! $this->s3StorageId) {
|
|
||||||
$this->dispatch('error', 'Please select an S3 storage.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blank($this->s3Path)) {
|
|
||||||
$this->dispatch('error', 'Please provide an S3 path.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean the path (remove leading slash if present)
|
|
||||||
$cleanPath = ltrim($this->s3Path, '/');
|
|
||||||
|
|
||||||
// Validate the S3 path early to prevent command injection in subsequent operations
|
|
||||||
if (! $this->validateS3Path($cleanPath)) {
|
|
||||||
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
|
||||||
|
|
||||||
// Validate bucket name early
|
|
||||||
if (! $this->validateBucketName($s3Storage->bucket)) {
|
|
||||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test connection
|
|
||||||
$s3Storage->testConnection();
|
|
||||||
|
|
||||||
// Build S3 disk configuration
|
|
||||||
$disk = Storage::build([
|
|
||||||
'driver' => 's3',
|
|
||||||
'region' => $s3Storage->region,
|
|
||||||
'key' => $s3Storage->key,
|
|
||||||
'secret' => $s3Storage->secret,
|
|
||||||
'bucket' => $s3Storage->bucket,
|
|
||||||
'endpoint' => $s3Storage->endpoint,
|
|
||||||
'use_path_style_endpoint' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Check if file exists
|
|
||||||
if (! $disk->exists($cleanPath)) {
|
|
||||||
$this->dispatch('error', 'File not found in S3. Please check the path.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file size
|
|
||||||
$this->s3FileSize = $disk->size($cleanPath);
|
|
||||||
|
|
||||||
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->s3FileSize = null;
|
|
||||||
|
|
||||||
return handleError($e, $this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function restoreFromS3(string $password = ''): bool|string
|
|
||||||
{
|
|
||||||
if (! verifyPasswordConfirmation($password, $this)) {
|
|
||||||
return 'The provided password is incorrect.';
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->authorize('update', $this->resource);
|
|
||||||
|
|
||||||
if (! ValidationPatterns::isValidContainerName($this->container)) {
|
|
||||||
$this->dispatch('error', 'Invalid container name.');
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->s3StorageId || blank($this->s3Path)) {
|
|
||||||
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_null($this->s3FileSize)) {
|
|
||||||
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->server) {
|
|
||||||
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->importRunning = true;
|
|
||||||
|
|
||||||
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
|
||||||
|
|
||||||
$key = $s3Storage->key;
|
|
||||||
$secret = $s3Storage->secret;
|
|
||||||
$bucket = $s3Storage->bucket;
|
|
||||||
$endpoint = $s3Storage->endpoint;
|
|
||||||
|
|
||||||
// Validate bucket name to prevent command injection
|
|
||||||
if (! $this->validateBucketName($bucket)) {
|
|
||||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean the S3 path
|
|
||||||
$cleanPath = ltrim($this->s3Path, '/');
|
|
||||||
|
|
||||||
// Validate the S3 path to prevent command injection
|
|
||||||
if (! $this->validateS3Path($cleanPath)) {
|
|
||||||
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get helper image
|
|
||||||
$helperImage = config('constants.coolify.helper_image');
|
|
||||||
$latestVersion = getHelperVersion();
|
|
||||||
$fullImageName = "{$helperImage}:{$latestVersion}";
|
|
||||||
|
|
||||||
// Get the database destination network
|
|
||||||
if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
|
||||||
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
|
|
||||||
} else {
|
|
||||||
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique names for this operation
|
|
||||||
$containerName = "s3-restore-{$this->resourceUuid}";
|
|
||||||
$helperTmpPath = '/tmp/'.basename($cleanPath);
|
|
||||||
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
|
|
||||||
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
|
|
||||||
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
|
|
||||||
|
|
||||||
// Prepare all commands in sequence
|
|
||||||
$commands = [];
|
|
||||||
|
|
||||||
// 1. Clean up any existing helper container and temp files from previous runs
|
|
||||||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
|
||||||
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
|
|
||||||
$commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true";
|
|
||||||
|
|
||||||
// 2. Start helper container on the database network
|
|
||||||
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
|
|
||||||
|
|
||||||
// 3. Configure S3 access in helper container
|
|
||||||
$escapedEndpoint = escapeshellarg($endpoint);
|
|
||||||
$escapedKey = escapeshellarg($key);
|
|
||||||
$escapedSecret = escapeshellarg($secret);
|
|
||||||
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
|
||||||
|
|
||||||
// 4. Check file exists in S3 (bucket and path already validated above)
|
|
||||||
$escapedBucket = escapeshellarg($bucket);
|
|
||||||
$escapedCleanPath = escapeshellarg($cleanPath);
|
|
||||||
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
|
|
||||||
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
|
|
||||||
|
|
||||||
// 5. Download from S3 to helper container (progress shown by default)
|
|
||||||
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
|
|
||||||
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
|
|
||||||
|
|
||||||
// 6. Copy from helper to server, then immediately to database container
|
|
||||||
$commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
|
|
||||||
$commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}";
|
|
||||||
|
|
||||||
// 7. Cleanup helper container and server temp file immediately (no longer needed)
|
|
||||||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
|
||||||
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
|
|
||||||
|
|
||||||
// 8. Build and execute restore command inside database container
|
|
||||||
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
|
|
||||||
|
|
||||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
|
||||||
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
|
||||||
$commands[] = "chmod +x {$scriptPath}";
|
|
||||||
$commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
|
|
||||||
|
|
||||||
// 9. Execute restore and cleanup temp files immediately after completion
|
|
||||||
$commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'";
|
|
||||||
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
|
||||||
|
|
||||||
// Execute all commands with cleanup event (as safety net for edge cases)
|
|
||||||
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
|
|
||||||
'containerName' => $containerName,
|
|
||||||
'serverTmpPath' => $serverTmpPath,
|
|
||||||
'scriptPath' => $scriptPath,
|
|
||||||
'containerTmpPath' => $containerTmpPath,
|
|
||||||
'container' => $this->container,
|
|
||||||
'serverId' => $this->server->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Track the activity ID
|
|
||||||
$this->activityId = $activity->id;
|
|
||||||
|
|
||||||
// Dispatch activity to the monitor and open slide-over
|
|
||||||
$this->dispatch('activityMonitor', $activity->id);
|
|
||||||
$this->dispatch('databaserestore');
|
|
||||||
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->importRunning = false;
|
|
||||||
handleError($e, $this);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function buildRestoreCommand(string $tmpPath): string
|
|
||||||
{
|
|
||||||
$morphClass = $this->resource->getMorphClass();
|
|
||||||
|
|
||||||
// Handle ServiceDatabase by checking the database type
|
|
||||||
if ($morphClass === \App\Models\ServiceDatabase::class) {
|
|
||||||
$dbType = $this->resource->databaseType();
|
|
||||||
if (str_contains($dbType, 'mysql')) {
|
|
||||||
$morphClass = 'mysql';
|
|
||||||
} elseif (str_contains($dbType, 'mariadb')) {
|
|
||||||
$morphClass = 'mariadb';
|
|
||||||
} elseif (str_contains($dbType, 'postgres')) {
|
|
||||||
$morphClass = 'postgresql';
|
|
||||||
} elseif (str_contains($dbType, 'mongo')) {
|
|
||||||
$morphClass = 'mongodb';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch ($morphClass) {
|
|
||||||
case \App\Models\StandaloneMariadb::class:
|
|
||||||
case 'mariadb':
|
|
||||||
$restoreCommand = $this->mariadbRestoreCommand;
|
|
||||||
if ($this->dumpAll) {
|
|
||||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
|
|
||||||
} else {
|
|
||||||
$restoreCommand .= " < {$tmpPath}";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case \App\Models\StandaloneMysql::class:
|
|
||||||
case 'mysql':
|
|
||||||
$restoreCommand = $this->mysqlRestoreCommand;
|
|
||||||
if ($this->dumpAll) {
|
|
||||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
|
|
||||||
} else {
|
|
||||||
$restoreCommand .= " < {$tmpPath}";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case \App\Models\StandalonePostgresql::class:
|
|
||||||
case 'postgresql':
|
|
||||||
$restoreCommand = $this->postgresqlRestoreCommand;
|
|
||||||
if ($this->dumpAll) {
|
|
||||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
|
|
||||||
} else {
|
|
||||||
$restoreCommand .= " {$tmpPath}";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case \App\Models\StandaloneMongodb::class:
|
|
||||||
case 'mongodb':
|
|
||||||
$restoreCommand = $this->mongodbRestoreCommand;
|
|
||||||
if ($this->dumpAll === false) {
|
|
||||||
$restoreCommand .= "{$tmpPath}";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
$restoreCommand = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $restoreCommand;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
825
app/Livewire/Project/Database/ImportForm.php
Normal file
825
app/Livewire/Project/Database/ImportForm.php
Normal file
|
|
@ -0,0 +1,825 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Project\Database;
|
||||||
|
|
||||||
|
use App\Models\S3Storage;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceDatabase;
|
||||||
|
use App\Models\StandaloneClickhouse;
|
||||||
|
use App\Models\StandaloneDragonfly;
|
||||||
|
use App\Models\StandaloneKeydb;
|
||||||
|
use App\Models\StandaloneMariadb;
|
||||||
|
use App\Models\StandaloneMongodb;
|
||||||
|
use App\Models\StandaloneMysql;
|
||||||
|
use App\Models\StandalonePostgresql;
|
||||||
|
use App\Models\StandaloneRedis;
|
||||||
|
use App\Support\ValidationPatterns;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class ImportForm extends Component
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a string is safe for use as an S3 bucket name.
|
||||||
|
* Allows alphanumerics, dots, dashes, and underscores.
|
||||||
|
*/
|
||||||
|
private function validateBucketName(string $bucket): bool
|
||||||
|
{
|
||||||
|
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a string is safe for use as an S3 path.
|
||||||
|
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
|
||||||
|
*/
|
||||||
|
private function validateS3Path(string $path): bool
|
||||||
|
{
|
||||||
|
// Must not be empty
|
||||||
|
if (empty($path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not contain dangerous shell metacharacters or command injection patterns
|
||||||
|
$dangerousPatterns = [
|
||||||
|
'..', // Directory traversal
|
||||||
|
'$(', // Command substitution
|
||||||
|
'`', // Backtick command substitution
|
||||||
|
'|', // Pipe
|
||||||
|
';', // Command separator
|
||||||
|
'&', // Background/AND
|
||||||
|
'>', // Redirect
|
||||||
|
'<', // Redirect
|
||||||
|
"\n", // Newline
|
||||||
|
"\r", // Carriage return
|
||||||
|
"\0", // Null byte
|
||||||
|
"'", // Single quote
|
||||||
|
'"', // Double quote
|
||||||
|
'\\', // Backslash
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($dangerousPatterns as $pattern) {
|
||||||
|
if (str_contains($path, $pattern)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
|
||||||
|
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a string is safe for use as a file path on the server.
|
||||||
|
*/
|
||||||
|
private function validateServerPath(string $path): bool
|
||||||
|
{
|
||||||
|
// Must be an absolute path
|
||||||
|
if (! str_starts_with($path, '/')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not contain dangerous shell metacharacters or command injection patterns
|
||||||
|
$dangerousPatterns = [
|
||||||
|
'..', // Directory traversal
|
||||||
|
'$(', // Command substitution
|
||||||
|
'`', // Backtick command substitution
|
||||||
|
'|', // Pipe
|
||||||
|
';', // Command separator
|
||||||
|
'&', // Background/AND
|
||||||
|
'>', // Redirect
|
||||||
|
'<', // Redirect
|
||||||
|
"\n", // Newline
|
||||||
|
"\r", // Carriage return
|
||||||
|
"\0", // Null byte
|
||||||
|
"'", // Single quote
|
||||||
|
'"', // Double quote
|
||||||
|
'\\', // Backslash
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($dangerousPatterns as $pattern) {
|
||||||
|
if (str_contains($path, $pattern)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
|
||||||
|
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool $unsupported = false;
|
||||||
|
|
||||||
|
// Store IDs instead of models for proper Livewire serialization
|
||||||
|
#[Locked]
|
||||||
|
public ?int $resourceId = null;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public ?string $resourceType = null;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public ?int $serverId = null;
|
||||||
|
|
||||||
|
// View-friendly properties to avoid computed property access in Blade
|
||||||
|
#[Locked]
|
||||||
|
public string $resourceUuid = '';
|
||||||
|
|
||||||
|
public string $resourceStatus = '';
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public string $resourceDbType = '';
|
||||||
|
|
||||||
|
public array $parameters = [];
|
||||||
|
|
||||||
|
public array $containers = [];
|
||||||
|
|
||||||
|
public bool $scpInProgress = false;
|
||||||
|
|
||||||
|
public bool $importRunning = false;
|
||||||
|
|
||||||
|
public ?string $filename = null;
|
||||||
|
|
||||||
|
public ?string $filesize = null;
|
||||||
|
|
||||||
|
public bool $isUploading = false;
|
||||||
|
|
||||||
|
public int $progress = 0;
|
||||||
|
|
||||||
|
public bool $error = false;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public string $container;
|
||||||
|
|
||||||
|
public array $importCommands = [];
|
||||||
|
|
||||||
|
public bool $dumpAll = false;
|
||||||
|
|
||||||
|
public string $restoreCommandText = '';
|
||||||
|
|
||||||
|
public string $customLocation = '';
|
||||||
|
|
||||||
|
public ?int $activityId = null;
|
||||||
|
|
||||||
|
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||||||
|
|
||||||
|
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||||||
|
|
||||||
|
public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
|
||||||
|
|
||||||
|
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
|
||||||
|
|
||||||
|
// S3 Restore properties
|
||||||
|
public array $availableS3Storages = [];
|
||||||
|
|
||||||
|
public ?int $s3StorageId = null;
|
||||||
|
|
||||||
|
public string $s3Path = '';
|
||||||
|
|
||||||
|
public ?int $s3FileSize = null;
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function resource()
|
||||||
|
{
|
||||||
|
if ($this->resourceId === null || $this->resourceType === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resourceType::find($this->resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function server()
|
||||||
|
{
|
||||||
|
if ($this->serverId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Server::ownedByCurrentTeam()->find($this->serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $listeners = [
|
||||||
|
'slideOverClosed' => 'resetActivityId',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function resetActivityId()
|
||||||
|
{
|
||||||
|
$this->activityId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
$this->parameters = get_route_parameters();
|
||||||
|
$this->getContainers();
|
||||||
|
$this->loadAvailableS3Storages();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedDumpAll($value)
|
||||||
|
{
|
||||||
|
$morphClass = $this->resource->getMorphClass();
|
||||||
|
|
||||||
|
// Handle ServiceDatabase by checking the database type
|
||||||
|
if ($morphClass === ServiceDatabase::class) {
|
||||||
|
$dbType = $this->resource->databaseType();
|
||||||
|
if (str_contains($dbType, 'mysql')) {
|
||||||
|
$morphClass = 'mysql';
|
||||||
|
} elseif (str_contains($dbType, 'mariadb')) {
|
||||||
|
$morphClass = 'mariadb';
|
||||||
|
} elseif (str_contains($dbType, 'postgres')) {
|
||||||
|
$morphClass = 'postgresql';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($morphClass) {
|
||||||
|
case StandaloneMariadb::class:
|
||||||
|
case 'mariadb':
|
||||||
|
if ($value === true) {
|
||||||
|
$this->mariadbRestoreCommand = <<<'EOD'
|
||||||
|
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
|
||||||
|
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
|
||||||
|
done && \
|
||||||
|
mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
|
||||||
|
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
|
||||||
|
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
|
||||||
|
EOD;
|
||||||
|
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
|
||||||
|
} else {
|
||||||
|
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case StandaloneMysql::class:
|
||||||
|
case 'mysql':
|
||||||
|
if ($value === true) {
|
||||||
|
$this->mysqlRestoreCommand = <<<'EOD'
|
||||||
|
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
|
||||||
|
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
|
||||||
|
done && \
|
||||||
|
mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
|
||||||
|
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
|
||||||
|
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
|
||||||
|
EOD;
|
||||||
|
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
|
||||||
|
} else {
|
||||||
|
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case StandalonePostgresql::class:
|
||||||
|
case 'postgresql':
|
||||||
|
if ($value === true) {
|
||||||
|
$this->postgresqlRestoreCommand = <<<'EOD'
|
||||||
|
psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
|
||||||
|
psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
|
||||||
|
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
|
||||||
|
EOD;
|
||||||
|
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||||||
|
} else {
|
||||||
|
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContainers()
|
||||||
|
{
|
||||||
|
$this->containers = [];
|
||||||
|
$teamId = data_get(auth()->user()->currentTeam(), 'id');
|
||||||
|
|
||||||
|
// Try to find resource by route parameter
|
||||||
|
$databaseUuid = data_get($this->parameters, 'database_uuid');
|
||||||
|
$stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
|
||||||
|
|
||||||
|
$resource = null;
|
||||||
|
if ($databaseUuid) {
|
||||||
|
// Standalone database route
|
||||||
|
$resource = getResourceByUuid($databaseUuid, $teamId);
|
||||||
|
if (is_null($resource)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
} elseif ($stackServiceUuid) {
|
||||||
|
// ServiceDatabase route - look up the service database
|
||||||
|
$serviceUuid = data_get($this->parameters, 'service_uuid');
|
||||||
|
$project = currentTeam()
|
||||||
|
->projects()
|
||||||
|
->select('id', 'uuid', 'team_id')
|
||||||
|
->where('uuid', data_get($this->parameters, 'project_uuid'))
|
||||||
|
->firstOrFail();
|
||||||
|
$environment = $project->environments()
|
||||||
|
->select('id', 'uuid', 'name', 'project_id')
|
||||||
|
->where('uuid', data_get($this->parameters, 'environment_uuid'))
|
||||||
|
->firstOrFail();
|
||||||
|
$service = $environment->services()->whereUuid($serviceUuid)->firstOrFail();
|
||||||
|
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
|
||||||
|
if (is_null($resource)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorize('view', $resource);
|
||||||
|
|
||||||
|
// Store IDs for Livewire serialization
|
||||||
|
$this->resourceId = $resource->id;
|
||||||
|
$this->resourceType = get_class($resource);
|
||||||
|
|
||||||
|
// Store view-friendly properties
|
||||||
|
$this->resourceStatus = $resource->status ?? '';
|
||||||
|
|
||||||
|
// Handle ServiceDatabase server access differently
|
||||||
|
if ($resource->getMorphClass() === ServiceDatabase::class) {
|
||||||
|
$server = $resource->service?->server;
|
||||||
|
if (! $server) {
|
||||||
|
abort(404, 'Server not found for this service database.');
|
||||||
|
}
|
||||||
|
$this->serverId = $server->id;
|
||||||
|
$this->container = $resource->name.'-'.$resource->service->uuid;
|
||||||
|
$this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
|
||||||
|
|
||||||
|
// Determine database type for ServiceDatabase
|
||||||
|
$dbType = $resource->databaseType();
|
||||||
|
if (str_contains($dbType, 'postgres')) {
|
||||||
|
$this->resourceDbType = 'standalone-postgresql';
|
||||||
|
} elseif (str_contains($dbType, 'mysql')) {
|
||||||
|
$this->resourceDbType = 'standalone-mysql';
|
||||||
|
} elseif (str_contains($dbType, 'mariadb')) {
|
||||||
|
$this->resourceDbType = 'standalone-mariadb';
|
||||||
|
} elseif (str_contains($dbType, 'mongo')) {
|
||||||
|
$this->resourceDbType = 'standalone-mongodb';
|
||||||
|
} else {
|
||||||
|
$this->resourceDbType = $dbType;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$server = $resource->destination?->server;
|
||||||
|
if (! $server) {
|
||||||
|
abort(404, 'Server not found for this database.');
|
||||||
|
}
|
||||||
|
$this->serverId = $server->id;
|
||||||
|
$this->container = $resource->uuid;
|
||||||
|
$this->resourceUuid = $resource->uuid;
|
||||||
|
$this->resourceDbType = $resource->type();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str($resource->status)->startsWith('running')) {
|
||||||
|
$this->containers[] = $this->container;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$resource->getMorphClass() === StandaloneRedis::class ||
|
||||||
|
$resource->getMorphClass() === StandaloneKeydb::class ||
|
||||||
|
$resource->getMorphClass() === StandaloneDragonfly::class ||
|
||||||
|
$resource->getMorphClass() === StandaloneClickhouse::class
|
||||||
|
) {
|
||||||
|
$this->unsupported = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
|
||||||
|
if ($resource->getMorphClass() === ServiceDatabase::class) {
|
||||||
|
$dbType = $resource->databaseType();
|
||||||
|
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
|
||||||
|
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
|
||||||
|
$this->unsupported = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkFile()
|
||||||
|
{
|
||||||
|
if (filled($this->customLocation)) {
|
||||||
|
// Validate the custom location to prevent command injection
|
||||||
|
if (! $this->validateServerPath($this->customLocation)) {
|
||||||
|
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->server) {
|
||||||
|
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$escapedPath = escapeshellarg($this->customLocation);
|
||||||
|
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
|
||||||
|
if (blank($result)) {
|
||||||
|
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->filename = $this->customLocation;
|
||||||
|
$this->dispatch('success', 'The file exists.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runImport(string $password = ''): bool|string
|
||||||
|
{
|
||||||
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
|
return 'The provided password is incorrect.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorize('update', $this->resource);
|
||||||
|
|
||||||
|
if (! ValidationPatterns::isValidContainerName($this->container)) {
|
||||||
|
$this->dispatch('error', 'Invalid container name.');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->filename === '') {
|
||||||
|
$this->dispatch('error', 'Please select a file to import.');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->server) {
|
||||||
|
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->importRunning = true;
|
||||||
|
$this->importCommands = [];
|
||||||
|
$backupFileName = "upload/{$this->resourceUuid}/restore";
|
||||||
|
|
||||||
|
// Check if an uploaded file exists first (takes priority over custom location)
|
||||||
|
if (Storage::exists($backupFileName)) {
|
||||||
|
$path = Storage::path($backupFileName);
|
||||||
|
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
|
||||||
|
instant_scp($path, $tmpPath, $this->server);
|
||||||
|
Storage::delete($backupFileName);
|
||||||
|
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
|
||||||
|
} elseif (filled($this->customLocation)) {
|
||||||
|
// Validate the custom location to prevent command injection
|
||||||
|
if (! $this->validateServerPath($this->customLocation)) {
|
||||||
|
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
|
||||||
|
$escapedCustomLocation = escapeshellarg($this->customLocation);
|
||||||
|
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
|
||||||
|
} else {
|
||||||
|
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the restore command to a script file
|
||||||
|
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
|
||||||
|
|
||||||
|
$restoreCommand = $this->buildRestoreCommand($tmpPath);
|
||||||
|
|
||||||
|
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||||
|
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||||
|
$this->importCommands[] = "chmod +x {$scriptPath}";
|
||||||
|
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
|
||||||
|
|
||||||
|
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
|
||||||
|
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||||||
|
|
||||||
|
if (! empty($this->importCommands)) {
|
||||||
|
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
|
||||||
|
'scriptPath' => $scriptPath,
|
||||||
|
'tmpPath' => $tmpPath,
|
||||||
|
'container' => $this->container,
|
||||||
|
'serverId' => $this->server->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Track the activity ID
|
||||||
|
$this->activityId = $activity->id;
|
||||||
|
|
||||||
|
// Dispatch activity to the monitor and open slide-over
|
||||||
|
$this->dispatch('activityMonitor', $activity->id);
|
||||||
|
$this->dispatch('databaserestore');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
handleError($e, $this);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
$this->filename = null;
|
||||||
|
$this->importCommands = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadAvailableS3Storages()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
|
||||||
|
->where('is_usable', true)
|
||||||
|
->get()
|
||||||
|
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
|
||||||
|
->toArray();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->availableS3Storages = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedS3Path($value)
|
||||||
|
{
|
||||||
|
// Reset validation state when path changes
|
||||||
|
$this->s3FileSize = null;
|
||||||
|
|
||||||
|
// Ensure path starts with a slash
|
||||||
|
if ($value !== null && $value !== '') {
|
||||||
|
$this->s3Path = str($value)->trim()->start('/')->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedS3StorageId()
|
||||||
|
{
|
||||||
|
// Reset validation state when storage changes
|
||||||
|
$this->s3FileSize = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkS3File()
|
||||||
|
{
|
||||||
|
if (! $this->s3StorageId) {
|
||||||
|
$this->dispatch('error', 'Please select an S3 storage.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blank($this->s3Path)) {
|
||||||
|
$this->dispatch('error', 'Please provide an S3 path.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the path (remove leading slash if present)
|
||||||
|
$cleanPath = ltrim($this->s3Path, '/');
|
||||||
|
|
||||||
|
// Validate the S3 path early to prevent command injection in subsequent operations
|
||||||
|
if (! $this->validateS3Path($cleanPath)) {
|
||||||
|
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||||
|
|
||||||
|
// Validate bucket name early
|
||||||
|
if (! $this->validateBucketName($s3Storage->bucket)) {
|
||||||
|
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
$s3Storage->testConnection();
|
||||||
|
|
||||||
|
// Build S3 disk configuration
|
||||||
|
$disk = Storage::build([
|
||||||
|
'driver' => 's3',
|
||||||
|
'region' => $s3Storage->region,
|
||||||
|
'key' => $s3Storage->key,
|
||||||
|
'secret' => $s3Storage->secret,
|
||||||
|
'bucket' => $s3Storage->bucket,
|
||||||
|
'endpoint' => $s3Storage->endpoint,
|
||||||
|
'use_path_style_endpoint' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if (! $disk->exists($cleanPath)) {
|
||||||
|
$this->dispatch('error', 'File not found in S3. Please check the path.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file size
|
||||||
|
$this->s3FileSize = $disk->size($cleanPath);
|
||||||
|
|
||||||
|
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->s3FileSize = null;
|
||||||
|
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restoreFromS3(string $password = ''): bool|string
|
||||||
|
{
|
||||||
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
|
return 'The provided password is incorrect.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorize('update', $this->resource);
|
||||||
|
|
||||||
|
if (! ValidationPatterns::isValidContainerName($this->container)) {
|
||||||
|
$this->dispatch('error', 'Invalid container name.');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->s3StorageId || blank($this->s3Path)) {
|
||||||
|
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_null($this->s3FileSize)) {
|
||||||
|
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->server) {
|
||||||
|
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->importRunning = true;
|
||||||
|
|
||||||
|
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||||
|
|
||||||
|
$key = $s3Storage->key;
|
||||||
|
$secret = $s3Storage->secret;
|
||||||
|
$bucket = $s3Storage->bucket;
|
||||||
|
$endpoint = $s3Storage->endpoint;
|
||||||
|
|
||||||
|
// Validate bucket name to prevent command injection
|
||||||
|
if (! $this->validateBucketName($bucket)) {
|
||||||
|
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the S3 path
|
||||||
|
$cleanPath = ltrim($this->s3Path, '/');
|
||||||
|
|
||||||
|
// Validate the S3 path to prevent command injection
|
||||||
|
if (! $this->validateS3Path($cleanPath)) {
|
||||||
|
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get helper image
|
||||||
|
$helperImage = config('constants.coolify.helper_image');
|
||||||
|
$latestVersion = getHelperVersion();
|
||||||
|
$fullImageName = "{$helperImage}:{$latestVersion}";
|
||||||
|
|
||||||
|
// Get the database destination network
|
||||||
|
if ($this->resource->getMorphClass() === ServiceDatabase::class) {
|
||||||
|
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
|
||||||
|
} else {
|
||||||
|
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique names for this operation
|
||||||
|
$containerName = "s3-restore-{$this->resourceUuid}";
|
||||||
|
$helperTmpPath = '/tmp/'.basename($cleanPath);
|
||||||
|
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
|
||||||
|
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
|
||||||
|
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
|
||||||
|
|
||||||
|
$escapedServerTmpPath = escapeshellarg($serverTmpPath);
|
||||||
|
$escapedContainerTmpPath = escapeshellarg($containerTmpPath);
|
||||||
|
$escapedScriptPath = escapeshellarg($scriptPath);
|
||||||
|
$escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}");
|
||||||
|
$escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}");
|
||||||
|
$escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}");
|
||||||
|
$restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}");
|
||||||
|
|
||||||
|
// Prepare all commands in sequence
|
||||||
|
$commands = [];
|
||||||
|
|
||||||
|
// 1. Clean up any existing helper container and temp files from previous runs
|
||||||
|
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||||
|
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
|
||||||
|
$commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true";
|
||||||
|
|
||||||
|
// 2. Start helper container on the database network
|
||||||
|
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
|
||||||
|
|
||||||
|
// 3. Configure S3 access in helper container
|
||||||
|
$escapedEndpoint = escapeshellarg($endpoint);
|
||||||
|
$escapedKey = escapeshellarg($key);
|
||||||
|
$escapedSecret = escapeshellarg($secret);
|
||||||
|
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||||
|
|
||||||
|
// 4. Check file exists in S3 (bucket and path already validated above)
|
||||||
|
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
|
||||||
|
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
|
||||||
|
|
||||||
|
// 5. Download from S3 to helper container (progress shown by default)
|
||||||
|
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
|
||||||
|
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
|
||||||
|
|
||||||
|
// 6. Copy from helper to server, then immediately to database container
|
||||||
|
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
|
||||||
|
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
|
||||||
|
|
||||||
|
// 7. Cleanup helper container and server temp file immediately (no longer needed)
|
||||||
|
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||||
|
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
|
||||||
|
|
||||||
|
// 8. Build and execute restore command inside database container
|
||||||
|
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
|
||||||
|
|
||||||
|
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||||
|
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}";
|
||||||
|
$commands[] = "chmod +x {$escapedScriptPath}";
|
||||||
|
$commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}";
|
||||||
|
|
||||||
|
// 9. Execute restore and cleanup temp files immediately after completion
|
||||||
|
$commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}";
|
||||||
|
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||||||
|
|
||||||
|
// Execute all commands with cleanup event (as safety net for edge cases)
|
||||||
|
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
|
||||||
|
'containerName' => $containerName,
|
||||||
|
'serverTmpPath' => $serverTmpPath,
|
||||||
|
'scriptPath' => $scriptPath,
|
||||||
|
'containerTmpPath' => $containerTmpPath,
|
||||||
|
'container' => $this->container,
|
||||||
|
'serverId' => $this->server->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Track the activity ID
|
||||||
|
$this->activityId = $activity->id;
|
||||||
|
|
||||||
|
// Dispatch activity to the monitor and open slide-over
|
||||||
|
$this->dispatch('activityMonitor', $activity->id);
|
||||||
|
$this->dispatch('databaserestore');
|
||||||
|
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->importRunning = false;
|
||||||
|
handleError($e, $this);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildRestoreCommand(string $tmpPath): string
|
||||||
|
{
|
||||||
|
$escapedTmpPath = escapeshellarg($tmpPath);
|
||||||
|
$morphClass = $this->resource->getMorphClass();
|
||||||
|
|
||||||
|
// Handle ServiceDatabase by checking the database type
|
||||||
|
if ($morphClass === ServiceDatabase::class) {
|
||||||
|
$dbType = $this->resource->databaseType();
|
||||||
|
if (str_contains($dbType, 'mysql')) {
|
||||||
|
$morphClass = 'mysql';
|
||||||
|
} elseif (str_contains($dbType, 'mariadb')) {
|
||||||
|
$morphClass = 'mariadb';
|
||||||
|
} elseif (str_contains($dbType, 'postgres')) {
|
||||||
|
$morphClass = 'postgresql';
|
||||||
|
} elseif (str_contains($dbType, 'mongo')) {
|
||||||
|
$morphClass = 'mongodb';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($morphClass) {
|
||||||
|
case StandaloneMariadb::class:
|
||||||
|
case 'mariadb':
|
||||||
|
$restoreCommand = $this->mariadbRestoreCommand;
|
||||||
|
if ($this->dumpAll) {
|
||||||
|
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
|
||||||
|
} else {
|
||||||
|
$restoreCommand .= " < {$escapedTmpPath}";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case StandaloneMysql::class:
|
||||||
|
case 'mysql':
|
||||||
|
$restoreCommand = $this->mysqlRestoreCommand;
|
||||||
|
if ($this->dumpAll) {
|
||||||
|
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
|
||||||
|
} else {
|
||||||
|
$restoreCommand .= " < {$escapedTmpPath}";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case StandalonePostgresql::class:
|
||||||
|
case 'postgresql':
|
||||||
|
$restoreCommand = $this->postgresqlRestoreCommand;
|
||||||
|
if ($this->dumpAll) {
|
||||||
|
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
|
||||||
|
} else {
|
||||||
|
$restoreCommand .= " {$escapedTmpPath}";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case StandaloneMongodb::class:
|
||||||
|
case 'mongodb':
|
||||||
|
$restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$restoreCommand = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $restoreCommand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,9 @@
|
||||||
|
|
||||||
use App\Actions\Database\StartDatabaseProxy;
|
use App\Actions\Database\StartDatabaseProxy;
|
||||||
use App\Actions\Database\StopDatabaseProxy;
|
use App\Actions\Database\StopDatabaseProxy;
|
||||||
use App\Helpers\SslHelper;
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\StandaloneKeydb;
|
use App\Models\StandaloneKeydb;
|
||||||
use App\Support\ValidationPatterns;
|
use App\Support\ValidationPatterns;
|
||||||
use Carbon\Carbon;
|
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
@ -42,25 +40,21 @@ class General extends Component
|
||||||
|
|
||||||
public ?string $customDockerRunOptions = null;
|
public ?string $customDockerRunOptions = null;
|
||||||
|
|
||||||
public ?string $dbUrl = null;
|
|
||||||
|
|
||||||
public ?string $dbUrlPublic = null;
|
|
||||||
|
|
||||||
public bool $isLogDrainEnabled = false;
|
public bool $isLogDrainEnabled = false;
|
||||||
|
|
||||||
public ?Carbon $certificateValidUntil = null;
|
public function getListeners(): array
|
||||||
|
|
||||||
public bool $enable_ssl = false;
|
|
||||||
|
|
||||||
public function getListeners()
|
|
||||||
{
|
{
|
||||||
$userId = Auth::id();
|
$user = Auth::user();
|
||||||
$teamId = Auth::user()->currentTeam()->id;
|
if (! $user) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$team = $user->currentTeam();
|
||||||
|
if (! $team) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
|
"echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
|
||||||
"echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
|
|
||||||
"echo-private:team.{$teamId},ServiceChecked" => 'refresh',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,12 +69,6 @@ public function mount()
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$existingCert = $this->database->sslCertificates()->first();
|
|
||||||
|
|
||||||
if ($existingCert) {
|
|
||||||
$this->certificateValidUntil = $existingCert->valid_until;
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +76,7 @@ public function mount()
|
||||||
|
|
||||||
protected function rules(): array
|
protected function rules(): array
|
||||||
{
|
{
|
||||||
$baseRules = [
|
return [
|
||||||
'name' => ValidationPatterns::nameRules(),
|
'name' => ValidationPatterns::nameRules(),
|
||||||
'description' => ValidationPatterns::descriptionRules(),
|
'description' => ValidationPatterns::descriptionRules(),
|
||||||
'keydbConf' => 'nullable|string',
|
'keydbConf' => 'nullable|string',
|
||||||
|
|
@ -101,13 +89,8 @@ protected function rules(): array
|
||||||
'publicPort' => 'nullable|integer|min:1|max:65535',
|
'publicPort' => 'nullable|integer|min:1|max:65535',
|
||||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||||
'customDockerRunOptions' => 'nullable|string',
|
'customDockerRunOptions' => 'nullable|string',
|
||||||
'dbUrl' => 'nullable|string',
|
|
||||||
'dbUrlPublic' => 'nullable|string',
|
|
||||||
'isLogDrainEnabled' => 'nullable|boolean',
|
'isLogDrainEnabled' => 'nullable|boolean',
|
||||||
'enable_ssl' => 'boolean',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return $baseRules;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function messages(): array
|
protected function messages(): array
|
||||||
|
|
@ -143,11 +126,7 @@ public function syncData(bool $toModel = false)
|
||||||
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
|
||||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||||
$this->database->enable_ssl = $this->enable_ssl;
|
|
||||||
$this->database->save();
|
$this->database->save();
|
||||||
|
|
||||||
$this->dbUrl = $this->database->internal_db_url;
|
|
||||||
$this->dbUrlPublic = $this->database->external_db_url;
|
|
||||||
} else {
|
} else {
|
||||||
$this->name = $this->database->name;
|
$this->name = $this->database->name;
|
||||||
$this->description = $this->database->description;
|
$this->description = $this->database->description;
|
||||||
|
|
@ -160,9 +139,6 @@ public function syncData(bool $toModel = false)
|
||||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||||
$this->enable_ssl = $this->database->enable_ssl;
|
|
||||||
$this->dbUrl = $this->database->internal_db_url;
|
|
||||||
$this->dbUrlPublic = $this->database->external_db_url;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,6 +187,7 @@ public function instantSave()
|
||||||
StopDatabaseProxy::run($this->database);
|
StopDatabaseProxy::run($this->database);
|
||||||
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
$this->dispatch('success', 'Database is no longer publicly accessible.');
|
||||||
}
|
}
|
||||||
|
$this->dispatch('databaseUpdated');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->isPublic = ! $this->isPublic;
|
$this->isPublic = ! $this->isPublic;
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
|
|
@ -219,9 +196,13 @@ public function instantSave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function databaseProxyStopped()
|
public function databaseProxyStopped(): void
|
||||||
{
|
{
|
||||||
$this->syncData();
|
$this->database->refresh();
|
||||||
|
$this->isPublic = $this->database->is_public;
|
||||||
|
$this->publicPort = $this->database->public_port;
|
||||||
|
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||||
|
$this->dispatch('databaseUpdated');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
|
|
@ -237,6 +218,7 @@ public function submit()
|
||||||
}
|
}
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
$this->dispatch('success', 'Database updated.');
|
$this->dispatch('success', 'Database updated.');
|
||||||
|
$this->dispatch('databaseUpdated');
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -248,65 +230,6 @@ public function submit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function instantSaveSSL()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->authorize('update', $this->database);
|
|
||||||
|
|
||||||
$this->syncData(true);
|
|
||||||
$this->dispatch('success', 'SSL configuration updated.');
|
|
||||||
} catch (Exception $e) {
|
|
||||||
return handleError($e, $this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function regenerateSslCertificate()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->authorize('update', $this->database);
|
|
||||||
|
|
||||||
$existingCert = $this->database->sslCertificates()->first();
|
|
||||||
|
|
||||||
if (! $existingCert) {
|
|
||||||
$this->dispatch('error', 'No existing SSL certificate found for this database.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$caCert = $this->server->sslCertificates()
|
|
||||||
->where('is_ca_certificate', true)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $caCert) {
|
|
||||||
$this->server->generateCaCertificate();
|
|
||||||
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $caCert) {
|
|
||||||
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SslHelper::generateSslCertificate(
|
|
||||||
commonName: $existingCert->common_name,
|
|
||||||
subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
|
|
||||||
resourceType: $existingCert->resource_type,
|
|
||||||
resourceId: $existingCert->resource_id,
|
|
||||||
serverId: $existingCert->server_id,
|
|
||||||
caCert: $caCert->ssl_certificate,
|
|
||||||
caKey: $caCert->ssl_private_key,
|
|
||||||
configurationDir: $existingCert->configuration_dir,
|
|
||||||
mountPath: $existingCert->mount_path,
|
|
||||||
isPemKeyFileRequired: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
|
|
||||||
} catch (Exception $e) {
|
|
||||||
handleError($e, $this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function refresh(): void
|
public function refresh(): void
|
||||||
{
|
{
|
||||||
$this->database->refresh();
|
$this->database->refresh();
|
||||||
|
|
|
||||||
26
app/Livewire/Project/Database/Keydb/StatusInfo.php
Normal file
26
app/Livewire/Project/Database/Keydb/StatusInfo.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Project\Database\Keydb;
|
||||||
|
|
||||||
|
use App\Models\StandaloneKeydb;
|
||||||
|
use App\Traits\HasDatabaseStatusInfo;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class StatusInfo extends Component
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
use HasDatabaseStatusInfo;
|
||||||
|
|
||||||
|
public StandaloneKeydb $database;
|
||||||
|
|
||||||
|
protected function databaseLabel(): string
|
||||||
|
{
|
||||||
|
return 'KeyDB';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function showPublicUrlPlaceholder(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue