Compare commits
22 Commits
docs/kuber
...
mergeDevCo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
364a8fad8f | ||
|
|
d0f9cd5ef0 | ||
|
|
5caf41cf28 | ||
|
|
2e7927a23e | ||
|
|
79ace0ac38 | ||
|
|
6e9f6a829d | ||
|
|
83085dc2d0 | ||
|
|
a039f17913 | ||
|
|
895b10cc2f | ||
|
|
fd532af745 | ||
|
|
6ff9b9c665 | ||
|
|
7bf829b599 | ||
|
|
a19272de73 | ||
|
|
51820bf0eb | ||
|
|
6686a44316 | ||
|
|
7f86efe563 | ||
|
|
de6fd128cf | ||
|
|
41955ed427 | ||
|
|
0e9b1020d9 | ||
|
|
923816b0b9 | ||
|
|
db9f69958b | ||
|
|
52b2fce7b0 |
4
Makefile
4
Makefile
@@ -918,9 +918,11 @@ generate-manpage: ## generate manpage
|
||||
.PHONY: devstar
|
||||
devstar:
|
||||
docker build -t devstar-studio:latest -f docker/Dockerfile.devstar .
|
||||
|
||||
|
||||
.PHONY: docker
|
||||
docker:
|
||||
docker build -t devstar.cn/devstar/webterminal:latest -f docker/Dockerfile.webTerminal .
|
||||
|
||||
docker build --disable-content-trust=false -t $(DOCKER_REF) .
|
||||
# support also build args docker build --build-arg GITEA_VERSION=v1.2.3 --build-arg TAGS="bindata sqlite sqlite_unlock_notify" .
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ RUN chown git:git /var/lib/gitea /etc/gitea
|
||||
COPY --from=build-env /tmp/local /
|
||||
COPY --from=build-env --chown=root:root /go/src/code.gitea.io/gitea/gitea /app/gitea/gitea
|
||||
COPY --from=build-env --chown=root:root /go/src/code.gitea.io/gitea/environment-to-ini /usr/local/bin/environment-to-ini
|
||||
|
||||
COPY --from=build-env --chown=root:root /go/src/code.gitea.io/gitea/webTerminal.sh /app/gitea/webTerminal.sh
|
||||
# git:git
|
||||
USER 1000:1000
|
||||
ENV GITEA_WORK_DIR=/var/lib/gitea
|
||||
|
||||
40
docker/Dockerfile.webTerminal
Normal file
40
docker/Dockerfile.webTerminal
Normal file
@@ -0,0 +1,40 @@
|
||||
FROM docker.io/library/ubuntu:24.04 AS build-env
|
||||
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
git \
|
||||
build-essential \
|
||||
cmake \
|
||||
libjson-c-dev \
|
||||
libwebsockets-dev
|
||||
|
||||
RUN git clone https://devstar.cn/devstar/webTerminal.git /home/webTerminal
|
||||
# 设置工作目录并构建
|
||||
WORKDIR /home/webTerminal/build
|
||||
RUN cmake ..
|
||||
RUN make && make install
|
||||
|
||||
|
||||
FROM ubuntu:24.04
|
||||
|
||||
# 从构建阶段复制编译好的程序
|
||||
COPY --from=build-env /home/webTerminal/build/ttyd /home/webTerminal/build/ttyd
|
||||
|
||||
# 只安装运行时需要的库
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
curl && \
|
||||
curl -fsSL https://mirrors.ustc.edu.cn/docker-ce/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc && \
|
||||
apt-get install -y tini \
|
||||
libjson-c-dev \
|
||||
libwebsockets-dev && \
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://mirrors.ustc.edu.cn/docker-ce/linux/ubuntu/ \
|
||||
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
|
||||
tee /etc/apt/sources.list.d/docker.list > /dev/null && \
|
||||
apt-get update && apt-get install -y docker-ce-cli && \
|
||||
apt remove --purge curl -y && apt autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["/home/webTerminal/build/ttyd", "-W", "bash"]
|
||||
7
go.mod
7
go.mod
@@ -128,6 +128,7 @@ require (
|
||||
google.golang.org/protobuf v1.36.6
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
k8s.io/apimachinery v0.33.3
|
||||
mvdan.cc/xurls/v2 v2.6.0
|
||||
strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251
|
||||
xorm.io/builder v0.3.13
|
||||
@@ -152,7 +153,6 @@ require (
|
||||
golang.org/x/term v0.32.0 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
k8s.io/apimachinery v0.33.3 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
|
||||
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
|
||||
@@ -167,9 +167,9 @@ require (
|
||||
dario.cat/mergo v1.0.1 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect
|
||||
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 // indirect
|
||||
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2
|
||||
github.com/ArtisanCloud/PowerSocialite/v3 v3.0.8 // indirect
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.21 // indirect
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.21
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
|
||||
github.com/DataDog/zstd v1.5.7 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
@@ -301,7 +301,6 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.uber.org/zap/exp v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // indirect
|
||||
|
||||
24
go.sum
24
go.sum
@@ -337,6 +337,8 @@ github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0g
|
||||
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
@@ -345,6 +347,8 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En
|
||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
|
||||
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/go-redis/redis/v7 v7.4.1 h1:PASvf36gyUpr2zdOUS/9Zqc80GbM+9BDyiJSJDDOrTI=
|
||||
@@ -355,7 +359,10 @@ github.com/go-redsync/redsync/v4 v4.13.0 h1:49X6GJfnbLGaIpBBREM/zA4uIMDXKAh1NDkv
|
||||
github.com/go-redsync/redsync/v4 v4.13.0/go.mod h1:HMW4Q224GZQz6x1Xc7040Yfgacukdzu7ifTDAKiyErQ=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE=
|
||||
@@ -611,11 +618,13 @@ github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -698,6 +707,8 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
@@ -706,6 +717,7 @@ github.com/steveyen/gtreap v0.1.0/go.mod h1:kl/5J7XbrOmlIbYIXdRHDDE5QxHqpk0cmkT7
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
@@ -782,8 +794,14 @@ gitlab.com/gitlab-org/api/client-go v0.127.0/go.mod h1:bYC6fPORKSmtuPRyD9Z2rtbAj
|
||||
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
|
||||
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
|
||||
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
|
||||
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
|
||||
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
|
||||
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
|
||||
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
|
||||
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
|
||||
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
@@ -812,8 +830,6 @@ golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ug
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
|
||||
35
models/devcontainer/devcontainer.go
Normal file
35
models/devcontainer/devcontainer.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/models/db"
|
||||
)
|
||||
|
||||
type Devcontainer struct {
|
||||
Id int64 `xorm:"BIGINT pk NOT NULL autoincr 'id' comment('主键,devContainerId')"`
|
||||
Name string `xorm:"VARCHAR(64) charset=utf8mb4 collate=utf8mb4_bin UNIQUE NOT NULL 'name' comment('devContainer名称,自动生成')"`
|
||||
DevcontainerHost string `xorm:"VARCHAR(256) charset=utf8mb4 collate=utf8mb4_bin NOT NULL 'devcontainer_host' comment('SSH Host')"`
|
||||
DevcontainerPort uint16 `xorm:"SMALLINT UNSIGNED NOT NULL 'devcontainer_port' comment('SSH Port')"`
|
||||
DevcontainerStatus uint16 `xorm:"SMALLINT UNSIGNED NOT NULL 'devcontainer_status' comment('SSH Status')"`
|
||||
DevcontainerUsername string `xorm:"VARCHAR(32) charset=utf8mb4 collate=utf8mb4_bin NOT NULL 'devcontainer_username' comment('SSH Username')"`
|
||||
DevcontainerWorkDir string `xorm:"VARCHAR(256) charset=utf8mb4 collate=utf8mb4_bin NOT NULL 'devcontainer_work_dir' comment('SSH 工作路径,典型值 ~/${project_name},256字节以内')"`
|
||||
RepoId int64 `xorm:"BIGINT NOT NULL FK('repo_id') REFERENCES repository(id) ON DELETE CASCADE 'repo_id' comment('repository表主键')"`
|
||||
UserId int64 `xorm:"BIGINT NOT NULL FK('user_id') REFERENCES user(id) ON DELETE CASCADE 'user_id' comment('user表主键')"`
|
||||
CreatedUnix int64 `xorm:"BIGINT 'created_unix' comment('创建时间戳')"`
|
||||
UpdatedUnix int64 `xorm:"BIGINT 'updated_unix' comment('更新时间戳')"`
|
||||
}
|
||||
|
||||
type DevcontainerOutput struct {
|
||||
Id int64 `xorm:"BIGINT pk NOT NULL autoincr 'id' comment('主键,devContainerId')"`
|
||||
RepoId int64 `xorm:"BIGINT NOT NULL unique(uniquename) 'repo_id' comment('repository表主键')"`
|
||||
UserId int64 `xorm:"BIGINT NOT NULL unique(uniquename) 'user_id' comment('user表主键')"`
|
||||
Status string `xorm:"VARCHAR(255) charset=utf8mb4 collate=utf8mb4_bin NOT NULL 'status' comment('status')"`
|
||||
Output string `xorm:"TEXT 'output' comment('output')"`
|
||||
Command string `xorm:"TEXT 'command' comment('command')"`
|
||||
ListId int64 `xorm:"BIGINT NOT NULL unique(uniquename) 'list_id' comment('list_id')"`
|
||||
DevcontainerId int64 `xorm:"BIGINT NOT NULL FK('devcontainer_id') REFERENCES devcontainer(id) ON DELETE CASCADE 'devcontainer_id' comment('devcontainer表主键')"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Devcontainer))
|
||||
db.RegisterModel(new(DevcontainerOutput))
|
||||
}
|
||||
@@ -14,10 +14,7 @@ func AddDBWeChatUser(x *xorm.Engine) error {
|
||||
// 创建数据库表格
|
||||
err := x.Sync(new(wechat_models.UserWechatOpenid))
|
||||
if err != nil {
|
||||
return ErrMigrateDevstarDatabase{
|
||||
Step: "create table 'user_wechat_openid'",
|
||||
Message: err.Error(),
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -124,6 +124,7 @@ type User struct {
|
||||
AllowGitHook bool
|
||||
AllowImportLocal bool // Allow migrate repository by local path
|
||||
AllowCreateOrganization bool `xorm:"DEFAULT true"`
|
||||
AllowCreateDevcontainer bool `xorm:"DEFAULT false"`
|
||||
|
||||
// true: the user is not allowed to log in Web UI. Git/SSH access could still be allowed (please refer to Git/SSH access related code/documents)
|
||||
ProhibitLogin bool `xorm:"NOT NULL DEFAULT false"`
|
||||
@@ -268,6 +269,11 @@ func (u *User) CanCreateOrganization() bool {
|
||||
return u.IsAdmin || (u.AllowCreateOrganization && !setting.Admin.DisableRegularOrgCreation)
|
||||
}
|
||||
|
||||
// CanCreateDevcontianer returns true if user can create organisation.
|
||||
func (u *User) CanCreateDevcontainer() bool {
|
||||
return u.AllowCreateDevcontainer
|
||||
}
|
||||
|
||||
// CanEditGitHook returns true if user can edit Git hooks.
|
||||
func (u *User) CanEditGitHook() bool {
|
||||
return !setting.DisableGitHooks && (u.IsAdmin || u.AllowGitHook)
|
||||
@@ -633,6 +639,7 @@ type CreateUserOverwriteOptions struct {
|
||||
KeepEmailPrivate optional.Option[bool]
|
||||
Visibility *structs.VisibleType
|
||||
AllowCreateOrganization optional.Option[bool]
|
||||
AllowCreateDevcontainer optional.Option[bool]
|
||||
EmailNotificationsPreference *string
|
||||
MaxRepoCreation *int
|
||||
Theme *string
|
||||
|
||||
@@ -58,6 +58,7 @@ func NewActionsUser() *User {
|
||||
LoginName: ActionsUserName,
|
||||
Type: UserTypeBot,
|
||||
AllowCreateOrganization: true,
|
||||
AllowCreateDevcontainer: false,
|
||||
Visibility: structs.VisibleTypePublic,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,25 +2,58 @@ package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
// CreateDockerClient 创建Docker客户端
|
||||
func CreateDockerClient(ctx context.Context) (*client.Client, error) {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
log.Info("检查 Docker 环境")
|
||||
// 1. 检查 Docker 环境
|
||||
dockerSocketPath, err := GetDockerSocketPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Info("dockerSocketPath: %s", dockerSocketPath)
|
||||
// 2. 创建docker client 并且检查是否运行
|
||||
|
||||
opts := []client.Opt{
|
||||
client.FromEnv,
|
||||
}
|
||||
if dockerSocketPath != "" {
|
||||
opts = append(opts, client.WithHost(dockerSocketPath))
|
||||
}
|
||||
cli, err := client.NewClientWithOpts(opts...)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = cli.Ping(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker未运行, %w", err)
|
||||
}
|
||||
return cli, nil
|
||||
}
|
||||
|
||||
// GetDockerSocketPath 获取Docker Socket路径
|
||||
func GetDockerSocketPath() (string, error) {
|
||||
// 检查环境变量
|
||||
socket, found := os.LookupEnv("DOCKER_HOST")
|
||||
if found {
|
||||
return socket, nil
|
||||
}
|
||||
// 检查常见的Docker socket路径
|
||||
socketPaths := []string{
|
||||
"/var/run/docker.sock",
|
||||
@@ -31,20 +64,55 @@ func GetDockerSocketPath() (string, error) {
|
||||
`\\.\pipe\docker_engine`,
|
||||
"$HOME/.docker/run/docker.sock",
|
||||
}
|
||||
|
||||
for _, path := range socketPaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path, nil
|
||||
// 测试Docker默认路径
|
||||
for _, p := range socketPaths {
|
||||
if _, err := os.Lstat(os.ExpandEnv(p)); err == nil {
|
||||
if strings.HasPrefix(p, `\\.\`) {
|
||||
return "npipe://" + filepath.ToSlash(os.ExpandEnv(p)), nil
|
||||
}
|
||||
return "unix://" + filepath.ToSlash(os.ExpandEnv(p)), nil
|
||||
}
|
||||
}
|
||||
// 找不到
|
||||
return "", fmt.Errorf("Docker未安装")
|
||||
}
|
||||
func GetContainerID(cli *client.Client, containerName string) (string, error) {
|
||||
containerJSON, err := cli.ContainerInspect(context.Background(), containerName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return containerJSON.ID, nil
|
||||
}
|
||||
|
||||
// 如果找不到,返回默认路径
|
||||
return "/var/run/docker.sock", nil
|
||||
func GetContainerStatus(cli *client.Client, containerID string) (string, error) {
|
||||
containerInfo, err := cli.ContainerInspect(context.Background(), containerID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
state := containerInfo.State
|
||||
return state.Status, nil
|
||||
}
|
||||
func PushImage(dockerHost string, username string, password string, registryUrl string, imageRef string) error {
|
||||
script := "docker " + "-H " + dockerHost + " login -u " + username + " -p " + password + " " + registryUrl + " "
|
||||
cmd := exec.Command("sh", "-c", script)
|
||||
_, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 推送到仓库
|
||||
script = "docker " + "-H " + dockerHost + " push " + imageRef
|
||||
cmd = exec.Command("sh", "-c", script)
|
||||
_, err = cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PullImage 拉取Docker镜像
|
||||
func PullImage(cli *client.Client, dockerHost, imageName string) error {
|
||||
ctx := context.Background()
|
||||
func PullImage(ctx context.Context, cli *client.Client, dockerHost, imageName string) error {
|
||||
|
||||
reader, err := cli.ImagePull(ctx, imageName, types.ImagePullOptions{})
|
||||
if err != nil {
|
||||
@@ -58,27 +126,26 @@ func PullImage(cli *client.Client, dockerHost, imageName string) error {
|
||||
}
|
||||
|
||||
// CreateAndStartContainer 创建并启动容器
|
||||
func CreateAndStartContainer(cli *client.Client, imageName string, cmd []string, env []string, binds []string, ports map[string]string, containerName string) error {
|
||||
ctx := context.Background()
|
||||
func CreateAndStartContainer(ctx context.Context, cli *client.Client, imageName string, cmd []string, env []string, binds []string, ports nat.PortSet, containerName string) error {
|
||||
|
||||
// 配置容器
|
||||
config := &container.Config{
|
||||
Image: imageName,
|
||||
Env: env,
|
||||
}
|
||||
|
||||
if ports != nil {
|
||||
config.ExposedPorts = ports
|
||||
}
|
||||
if cmd != nil {
|
||||
config.Cmd = cmd
|
||||
}
|
||||
|
||||
hostConfig := &container.HostConfig{
|
||||
Binds: binds,
|
||||
}
|
||||
|
||||
// 如果有端口映射配置
|
||||
if ports != nil && len(ports) > 0 {
|
||||
// 这里可以根据需要添加端口映射逻辑
|
||||
// hostConfig.PortBindings = portBindings
|
||||
RestartPolicy: container.RestartPolicy{
|
||||
Name: "always", // 设置为 always
|
||||
},
|
||||
PublishAllPorts: true,
|
||||
}
|
||||
|
||||
// 创建容器
|
||||
@@ -92,8 +159,7 @@ func CreateAndStartContainer(cli *client.Client, imageName string, cmd []string,
|
||||
}
|
||||
|
||||
// DeleteContainer 停止并删除指定名称的容器
|
||||
func DeleteContainer(cli *client.Client, containerName string) error {
|
||||
ctx := context.Background()
|
||||
func DeleteContainer(ctx context.Context, cli *client.Client, containerName string) error {
|
||||
|
||||
// 首先尝试停止容器
|
||||
timeout := 10
|
||||
@@ -115,3 +181,86 @@ func DeleteContainer(cli *client.Client, containerName string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
func IsContainerNotFound(err error) bool {
|
||||
if client.IsErrNotFound(err) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取容器端口映射到了主机哪个端口,参数: DockerClient、containerName、容器端口号
|
||||
func GetMappedPort(ctx context.Context, containerName string, port string) (uint16, error) {
|
||||
// 创建 Docker 客户端
|
||||
cli, err := CreateDockerClient(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 获取容器 ID
|
||||
containerID, err := GetContainerID(cli, containerName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 获取容器详细信息
|
||||
containerJSON, err := cli.ContainerInspect(context.Background(), containerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 获取端口映射信息
|
||||
portBindings := containerJSON.NetworkSettings.Ports
|
||||
for containerPort, bindings := range portBindings {
|
||||
for _, binding := range bindings {
|
||||
log.Info("容器端口 %s 映射到主机 %s 端口 %s \n", containerPort, binding.HostIP, binding.HostPort)
|
||||
if containerPort.Port() == port {
|
||||
port_64, err := strconv.ParseUint(binding.HostPort, 10, 16)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint16(port_64), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("容器未开放端口 %s", port)
|
||||
}
|
||||
func ExecCommandInContainer(ctx context.Context, cli *client.Client, containerName string, command string) (string, error) {
|
||||
containerID, err := GetContainerID(cli, containerName)
|
||||
if err != nil {
|
||||
log.Info("创建执行实例失败", err)
|
||||
return "", err
|
||||
}
|
||||
cmdList := []string{"sh", "-c", command}
|
||||
execConfig := types.ExecConfig{
|
||||
Cmd: cmdList,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
}
|
||||
// 创建执行实例
|
||||
exec, err := cli.ContainerExecCreate(context.Background(), containerID, execConfig)
|
||||
if err != nil {
|
||||
log.Info("创建执行实例失败", err)
|
||||
return "", err
|
||||
}
|
||||
// 附加到执行实例
|
||||
resp, err := cli.ContainerExecAttach(context.Background(), exec.ID, types.ExecStartCheck{})
|
||||
if err != nil {
|
||||
log.Info("命令附加到执行实例失败", err)
|
||||
return "", err
|
||||
}
|
||||
defer resp.Close()
|
||||
// 启动执行实例
|
||||
err = cli.ContainerExecStart(context.Background(), exec.ID, types.ExecStartCheck{})
|
||||
if err != nil {
|
||||
log.Info("启动执行实例失败", err)
|
||||
return "", err
|
||||
}
|
||||
// 自定义缓冲区
|
||||
var outBuf, errBuf strings.Builder
|
||||
|
||||
// 读取输出
|
||||
_, err = stdcopy.StdCopy(&outBuf, &errBuf, resp.Reader)
|
||||
if err != nil {
|
||||
log.Info("Error reading output for command %v: %v\n", command, err)
|
||||
return "", err
|
||||
}
|
||||
return outBuf.String() + errBuf.String(), nil
|
||||
}
|
||||
|
||||
14
modules/setting/devcontainer.go
Normal file
14
modules/setting/devcontainer.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package setting
|
||||
|
||||
var DevContainerConfig = struct {
|
||||
Enable bool
|
||||
Web_Terminal_Image string
|
||||
Web_Terminal_Container string
|
||||
}{}
|
||||
|
||||
func loadDevContainerSettingsFrom(rootCfg ConfigProvider) {
|
||||
sec := rootCfg.Section("devcontainer")
|
||||
DevContainerConfig.Enable = sec.Key("ENABLE").MustBool(true)
|
||||
DevContainerConfig.Web_Terminal_Image = sec.Key("WEB_TERMINAL_IMAGE").MustString("devstar.cn/devstar/webterminal:latest")
|
||||
DevContainerConfig.Web_Terminal_Container = sec.Key("WEB_TERMINAL_CONTAINER").MustString("")
|
||||
}
|
||||
@@ -219,6 +219,7 @@ func LoadSettings() {
|
||||
loadFederationFrom(CfgProvider)
|
||||
loadRunnerSettingsFrom(CfgProvider)
|
||||
loadK8sSettingsFrom(CfgProvider)
|
||||
loadDevContainerSettingsFrom(CfgProvider)
|
||||
loadWechatSettingsFrom(CfgProvider)
|
||||
}
|
||||
|
||||
@@ -229,6 +230,7 @@ func LoadSettingsForInstall() {
|
||||
loadMailerFrom(CfgProvider)
|
||||
loadRunnerSettingsFrom(CfgProvider)
|
||||
loadK8sSettingsFrom(CfgProvider)
|
||||
loadDevContainerSettingsFrom(CfgProvider)
|
||||
loadWechatSettingsFrom(CfgProvider)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ type EditUserOption struct {
|
||||
MaxRepoCreation *int `json:"max_repo_creation"`
|
||||
ProhibitLogin *bool `json:"prohibit_login"`
|
||||
AllowCreateOrganization *bool `json:"allow_create_organization"`
|
||||
AllowCreateDevcontainer *bool `json:"allow_create_devcontainer"`
|
||||
Restricted *bool `json:"restricted"`
|
||||
Visibility string `json:"visibility" binding:"In(,public,limited,private)"`
|
||||
}
|
||||
|
||||
@@ -303,6 +303,9 @@ log_root_path = Log Path
|
||||
log_root_path_helper = Log files will be written to this directory.
|
||||
|
||||
optional_title = Optional Settings
|
||||
devcontainer_title = DevContainer Settings
|
||||
devcontainer_enable = Enable DevContainer
|
||||
web_terminal_failed = Failed to start web terminal: %v
|
||||
k8s_title = Kubernetes Settings
|
||||
k8s_enable = Enable Kubernetes
|
||||
k8s_url = Kubernetes API URL
|
||||
@@ -3152,6 +3155,7 @@ users.allow_git_hook = May Create Git Hooks
|
||||
users.allow_git_hook_tooltip = Git Hooks are executed as the OS user running Gitea and will have the same level of host access. As a result, users with this special Git Hook privilege can access and modify all Gitea repositories as well as the database used by Gitea. Consequently they are also able to gain Gitea administrator privileges.
|
||||
users.allow_import_local = May Import Local Repositories
|
||||
users.allow_create_organization = May Create Organizations
|
||||
users.allow_create_devcontainer= May Create Devcontainers
|
||||
users.update_profile = Update User Account
|
||||
users.delete_account = Delete User Account
|
||||
users.cannot_delete_self = "You cannot delete yourself"
|
||||
|
||||
@@ -298,6 +298,9 @@ log_root_path=日志路径
|
||||
log_root_path_helper=日志文件将写入此目录。
|
||||
|
||||
optional_title=可选设置
|
||||
devcontainer_title = DevContainer设置
|
||||
devcontainer_enable = 启用 DevContainer
|
||||
web_terminal_failed = 启动 WebTerminal 失败: %v
|
||||
k8s_title = Kubernetes设置
|
||||
k8s_enable = 启用 Kubernetes
|
||||
k8s_url = Kubernetes API 地址
|
||||
@@ -3141,6 +3144,7 @@ users.allow_git_hook=允许创建 Git 钩子
|
||||
users.allow_git_hook_tooltip=Git 钩子将会以操作系统用户运行,拥有同样的主机访问权限。因此,拥有此特殊的 Git 钩子权限将能够访问合修改所有的 Gitea 仓库或者 Gitea 的数据库。同时也能获得 Gitea 的管理员权限。
|
||||
users.allow_import_local=允许导入本地仓库
|
||||
users.allow_create_organization=允许创建组织
|
||||
users.allow_create_devcontainer=允许创建开发容器
|
||||
users.update_profile=更新帐户
|
||||
users.delete_account=删除帐户
|
||||
users.cannot_delete_self=您不能删除自己
|
||||
|
||||
224
routers/api/devcontainer/devcontainer.go
Normal file
224
routers/api/devcontainer/devcontainer.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
web_module "code.gitea.io/gitea/modules/web"
|
||||
Result "code.gitea.io/gitea/routers/entity"
|
||||
context "code.gitea.io/gitea/services/context"
|
||||
gitea_web_context "code.gitea.io/gitea/services/context"
|
||||
devcontainer_service "code.gitea.io/gitea/services/devcontainer"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
)
|
||||
|
||||
// CreateRepoDevcontainer 创建 某用户在某仓库的 DevContainer
|
||||
//
|
||||
// POST /api/devcontainer
|
||||
// 请求体参数:
|
||||
// -- repoId: 需要为哪个仓库创建 DevContainer
|
||||
// -- sshPublicKeyList: 列表,填入用户希望临时使用的SSH会话加密公钥
|
||||
// 注意:必须携带 用户登录凭证
|
||||
func CreateRepoDevcontainer(ctx *context.Context) {
|
||||
|
||||
// 1. 检查用户登录状态,若未登录则返回未授权错误
|
||||
if ctx == nil || ctx.Doer == nil {
|
||||
Result.RespUnauthorizedFailure.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 检查表单校验规则是否失败
|
||||
if ctx.HasError() {
|
||||
// POST Binding 表单正则表达式校验失败,返回 API 错误信息
|
||||
failedToValidateFormData := &Result.ResultType{
|
||||
Code: Result.RespFailedIllegalParams.Code,
|
||||
Msg: Result.RespFailedIllegalParams.Msg,
|
||||
Data: map[string]string{
|
||||
"ErrorMsg": ctx.GetErrMsg(),
|
||||
},
|
||||
}
|
||||
failedToValidateFormData.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 解析 repoId
|
||||
form := web_module.GetForm(ctx).(*forms.CreateRepoDevcontainerForm)
|
||||
repoId, err := strconv.ParseInt(form.RepoId, 10, 64)
|
||||
if err != nil || repoId <= 0 {
|
||||
failedToParseRepoId := Result.ResultType{
|
||||
Code: Result.RespFailedIllegalParams.Code,
|
||||
Msg: Result.RespFailedIllegalParams.Msg,
|
||||
Data: map[string]string{
|
||||
// fix nullptr dereference of `err.Error()` when repoId == 0
|
||||
"ErrorMsg": "repoId 必须是正数",
|
||||
},
|
||||
}
|
||||
failedToParseRepoId.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 调用 API Service 层创建 DevContainer
|
||||
repo, err := repo.GetRepositoryByID(ctx, repoId)
|
||||
if err != nil {
|
||||
errCreateDevcontainer := Result.ResultType{
|
||||
Code: Result.RespFailedCreateDevcontainer.Code,
|
||||
Msg: Result.RespFailedCreateDevcontainer.Msg,
|
||||
Data: map[string]string{
|
||||
"ErrorMsg": "repo not found",
|
||||
},
|
||||
}
|
||||
errCreateDevcontainer.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
return
|
||||
}
|
||||
devcontainer_service.CreateDevcontainerConfiguration(repo, ctx.Doer)
|
||||
|
||||
err = devcontainer_service.CreateDevcontainerAPIService(ctx, repo, ctx.Doer, form.SSHPublicKeyList, false)
|
||||
if err != nil {
|
||||
errCreateDevcontainer := Result.ResultType{
|
||||
Code: Result.RespFailedCreateDevcontainer.Code,
|
||||
Msg: Result.RespFailedCreateDevcontainer.Msg,
|
||||
Data: map[string]string{
|
||||
"ErrorMsg": err.Error(),
|
||||
},
|
||||
}
|
||||
errCreateDevcontainer.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 创建 DevContainer 成功,直接返回
|
||||
Result.RespSuccess.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
}
|
||||
|
||||
// GetDevcontainer 查找某用户在某仓库的 DevContainer
|
||||
//
|
||||
// GET /api/devcontainer
|
||||
// 请求体参数:
|
||||
// -- repoId: 需要为哪个仓库创建 DevContainer
|
||||
// -- wait: 是否等待 DevContainer 就绪(默认为 false 直接返回“未就绪”,否则阻塞等待)
|
||||
// -- UserPublicKey
|
||||
// 注意:必须携带 用户登录凭证
|
||||
func GetDevcontainer(ctx *gitea_web_context.Context) {
|
||||
// 1. 检查用户登录状态,若未登录则返回未授权错误
|
||||
if ctx == nil || ctx.Doer == nil {
|
||||
Result.RespUnauthorizedFailure.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 取得参数
|
||||
repoIdStr := ctx.FormString("repoId")
|
||||
UserPublicKey := ctx.FormString("userPublicKey")
|
||||
log.Info(UserPublicKey)
|
||||
repoId, err := strconv.ParseInt(repoIdStr, 10, 64)
|
||||
if err != nil || repoId <= 0 {
|
||||
Result.RespFailedIllegalParams.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
return
|
||||
}
|
||||
repoDevcontainerVO, err := devcontainer_service.OpenDevcontainerAPIService(ctx, ctx.Doer.ID, repoId)
|
||||
if err != nil {
|
||||
failureGetDevcontainer := Result.ResultType{
|
||||
Code: Result.RespFailedOpenDevcontainer.Code,
|
||||
Msg: Result.RespFailedOpenDevcontainer.Msg,
|
||||
Data: map[string]any{
|
||||
"ErrorMsg": err.Error(),
|
||||
},
|
||||
}
|
||||
failureGetDevcontainer.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 封装返回成功信息
|
||||
successGetDevContainer := Result.ResultType{
|
||||
Code: Result.RespSuccess.Code,
|
||||
Msg: Result.RespSuccess.Msg,
|
||||
Data: repoDevcontainerVO,
|
||||
}
|
||||
successGetDevContainer.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
}
|
||||
|
||||
// DeleteRepoDevcontainer 删除某仓库的 DevContainer
|
||||
//
|
||||
// DELETE /api/devcontainer
|
||||
// 请求体参数:
|
||||
// -- repoId: 需要为哪个仓库创建 DevContainer
|
||||
// 注意:必须携带 用户登录凭证
|
||||
func DeleteRepoDevcontainer(ctx *gitea_web_context.Context) {
|
||||
// 1. 检查用户登录状态,若未登录则返回未授权错误
|
||||
if ctx == nil || ctx.Doer == nil {
|
||||
Result.RespUnauthorizedFailure.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 取得参数 repoId
|
||||
repoIdStr := ctx.FormString("repoId")
|
||||
repoId, err := strconv.ParseInt(repoIdStr, 10, 64)
|
||||
if err != nil || repoId <= 0 {
|
||||
Result.RespFailedIllegalParams.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
return
|
||||
}
|
||||
|
||||
err = devcontainer_service.DeleteDevContainer(ctx, ctx.Doer.ID, repoId)
|
||||
if err != nil {
|
||||
failureDeleteDevcontainer := Result.ResultType{
|
||||
Code: Result.RespFailedDeleteDevcontainer.Code,
|
||||
Msg: Result.RespFailedDeleteDevcontainer.Msg,
|
||||
Data: map[string]any{
|
||||
"ErrorMsg": err.Error(),
|
||||
},
|
||||
}
|
||||
failureDeleteDevcontainer.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 删除成功,返回提示信息
|
||||
Result.RespSuccess.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
}
|
||||
|
||||
// ListUserDevcontainers 枚举已登录用户所有的 DevContainers
|
||||
//
|
||||
// GET /api/devcontainer/user
|
||||
// 请求输入参数:
|
||||
// - page: 当前第几页(默认第1页),从1开始计数
|
||||
// - pageSize: 每页记录数(默认值 setting.UI.Admin.DevContainersPagingNum)
|
||||
func ListUserDevcontainers(ctx *gitea_web_context.Context) {
|
||||
|
||||
// 1. 检查用户登录状态,若未登录则返回未授权错误
|
||||
if ctx.Doer == nil {
|
||||
Result.RespUnauthorizedFailure.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 查询数据库 当前登录用户拥有写入权限的仓库
|
||||
userPage := ctx.FormInt("page")
|
||||
if userPage <= 0 {
|
||||
userPage = 1
|
||||
}
|
||||
userPageSize := ctx.FormInt("page_size")
|
||||
if userPageSize <= 0 || userPageSize > 50 {
|
||||
userPageSize = 50
|
||||
}
|
||||
userDevcontainersVO, err := devcontainer_service.GetDevcontainersList(ctx, ctx.Doer, userPage, userPageSize)
|
||||
|
||||
if err != nil {
|
||||
resultFailed2ListUserDevcontainerList := Result.ResultType{
|
||||
Code: Result.RespFailedListUserDevcontainers.Code,
|
||||
Msg: Result.RespFailedListUserDevcontainers.Msg,
|
||||
Data: map[string]string{
|
||||
"ErrorMsg": err.Error(),
|
||||
},
|
||||
}
|
||||
resultFailed2ListUserDevcontainerList.RespondJson2HttpResponseWriter(ctx.Resp)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 封装VO
|
||||
resultListUserDevcontainersVO := Result.ResultType{
|
||||
Code: Result.RespSuccess.Code,
|
||||
Msg: Result.RespSuccess.Msg,
|
||||
Data: userDevcontainersVO,
|
||||
}
|
||||
|
||||
// 4. JSON序列化,写入输出流
|
||||
responseWriter := ctx.Resp
|
||||
resultListUserDevcontainersVO.RespondJson2HttpResponseWriter(responseWriter)
|
||||
}
|
||||
@@ -245,6 +245,7 @@ func EditUser(ctx *context.APIContext) {
|
||||
AllowImportLocal: optional.FromPtr(form.AllowImportLocal),
|
||||
MaxRepoCreation: optional.FromPtr(form.MaxRepoCreation),
|
||||
AllowCreateOrganization: optional.FromPtr(form.AllowCreateOrganization),
|
||||
AllowCreateDevcontainer: optional.FromPtr(form.AllowCreateDevcontainer),
|
||||
IsRestricted: optional.FromPtr(form.Restricted),
|
||||
}
|
||||
|
||||
|
||||
33
routers/entity/devcontainer_result_constants.go
Normal file
33
routers/entity/devcontainer_result_constants.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package entity
|
||||
|
||||
// 错误码 110xx 表示 devContainer 相关错误信息
|
||||
|
||||
// RespFailedIllegalParams 仓库ID参数无效
|
||||
var RespFailedIllegalParams = ResultType{
|
||||
Code: 11002,
|
||||
Msg: "无效参数",
|
||||
}
|
||||
|
||||
// RespFailedCreateDevcontainer 创建 DevContainer 失败
|
||||
var RespFailedCreateDevcontainer = ResultType{
|
||||
Code: 11003,
|
||||
Msg: "创建 DevContainer 失败",
|
||||
}
|
||||
|
||||
// RespFailedOpenDevcontainer 打开 DevContainer 失败
|
||||
var RespFailedOpenDevcontainer = ResultType{
|
||||
Code: 11004,
|
||||
Msg: "打开 DevContainer 失败",
|
||||
}
|
||||
|
||||
// RespFailedDeleteDevcontainer 删除 DevContainer 失败
|
||||
var RespFailedDeleteDevcontainer = ResultType{
|
||||
Code: 11005,
|
||||
Msg: "删除 DevContainer 失败",
|
||||
}
|
||||
|
||||
// RespFailedListUserDevcontainers 查询用户 DevContainer 列表失败
|
||||
var RespFailedListUserDevcontainers = ResultType{
|
||||
Code: 11006,
|
||||
Msg: "查询用户 DevContainer 列表失败",
|
||||
}
|
||||
58
routers/entity/result.go
Normal file
58
routers/entity/result.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
// ResultType 定义了响应格式
|
||||
type ResultType struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data interface{} `json:"data,omitempty"` // Data 字段可选
|
||||
}
|
||||
|
||||
// RespondJson2HttpResponseWriter
|
||||
// 将 ResultType 响应转换为 JSON 并写入响应
|
||||
func (t *ResultType) RespondJson2HttpResponseWriter(w http.ResponseWriter) {
|
||||
responseBytes, err := json.Marshal(*t)
|
||||
if err != nil {
|
||||
// 只有序列化 ResultType 失败时候, 才返回 HTTP 500 Internal Server Error
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError)+": failed to marshal JSON", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 序列化 ResultType 成功,无论成功或者失败,统一返回 HTTP 200 OK
|
||||
if setting.CORSConfig.Enabled {
|
||||
AllowOrigin := setting.CORSConfig.AllowDomain[0]
|
||||
if AllowOrigin == "" {
|
||||
AllowOrigin = "*"
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Access-Control-Allow-Origin", AllowOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", strings.Join(setting.CORSConfig.Methods, ","))
|
||||
w.Header().Set("Access-Control-Allow-Headers", strings.Join(setting.CORSConfig.Headers, ","))
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(responseBytes)
|
||||
}
|
||||
|
||||
// RespSuccess 操作成功常量
|
||||
var RespSuccess = ResultType{
|
||||
Code: 0,
|
||||
Msg: "操作成功",
|
||||
}
|
||||
|
||||
// RespUnauthorizedFailure 获取 devContainer 信息失败:用户未授权
|
||||
var RespUnauthorizedFailure = ResultType{
|
||||
Code: 00001,
|
||||
Msg: "未登录,禁止访问",
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
other_context "context"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
@@ -13,13 +14,13 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
db_install "code.gitea.io/gitea/models/db/install"
|
||||
system_model "code.gitea.io/gitea/models/system"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/auth/password/hash"
|
||||
docker_module "code.gitea.io/gitea/modules/docker"
|
||||
"code.gitea.io/gitea/modules/generate"
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
@@ -35,6 +36,7 @@ import (
|
||||
"code.gitea.io/gitea/routers/common"
|
||||
auth_service "code.gitea.io/gitea/services/auth"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
devcontainer_service "code.gitea.io/gitea/services/devcontainer"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
runners_service "code.gitea.io/gitea/services/runners"
|
||||
"code.gitea.io/gitea/services/versioned_migration"
|
||||
@@ -225,6 +227,17 @@ func checkDatabase(ctx *context.Context, form *forms.InstallForm) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func checkDocker(ctx *context.Context, form *forms.InstallForm) bool {
|
||||
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
if err != nil {
|
||||
ctx.Data["Err_DevContainer"] = true
|
||||
return false
|
||||
}
|
||||
cli.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// SubmitInstall response for submit install items
|
||||
func SubmitInstall(ctx *context.Context) {
|
||||
if setting.InstallLock {
|
||||
@@ -613,7 +626,14 @@ func SubmitInstall(ctx *context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
runners_service.RegistGlobalRunner(ctx)
|
||||
if form.K8sEnable {
|
||||
//K8s环境检测
|
||||
} else {
|
||||
if !checkDocker(ctx, &form) {
|
||||
ctx.RenderWithErr("There is no docker environment", tplInstall, &form)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setting.ClearEnvConfigKeys()
|
||||
log.Info("First-time run install finished!")
|
||||
@@ -622,7 +642,17 @@ func SubmitInstall(ctx *context.Context) {
|
||||
go func() {
|
||||
// Sleep for a while to make sure the user's browser has loaded the post-install page and its assets (images, css, js)
|
||||
// What if this duration is not long enough? That's impossible -- if the user can't load the simple page in time, how could they install or use Gitea in the future ....
|
||||
time.Sleep(3 * time.Second)
|
||||
otherCtx := other_context.Background()
|
||||
if form.K8sEnable {
|
||||
//K8s环境检测
|
||||
} else {
|
||||
err = devcontainer_service.RegistWebTerminal(otherCtx)
|
||||
if err != nil {
|
||||
ctx.RenderWithErr(ctx.Tr("install.web_terminal_failed", err), tplInstall, &form)
|
||||
return
|
||||
}
|
||||
}
|
||||
runners_service.RegistGlobalRunner(otherCtx)
|
||||
|
||||
// Now get the http.Server from this request and shut it down
|
||||
// NB: This is not our hammerable graceful shutdown this is http.Server.Shutdown
|
||||
|
||||
@@ -224,7 +224,6 @@ func prepareUserInfo(ctx *context.Context) *user_model.User {
|
||||
return nil
|
||||
}
|
||||
ctx.Data["User"] = u
|
||||
|
||||
if u.LoginSource > 0 {
|
||||
ctx.Data["LoginSource"], err = auth.GetSourceByID(ctx, u.LoginSource)
|
||||
if err != nil {
|
||||
@@ -437,6 +436,7 @@ func EditUserPost(ctx *context.Context) {
|
||||
AllowImportLocal: optional.Some(form.AllowImportLocal),
|
||||
MaxRepoCreation: optional.Some(form.MaxRepoCreation),
|
||||
AllowCreateOrganization: optional.Some(form.AllowCreateOrganization),
|
||||
AllowCreateDevcontainer: optional.Some(form.AllowCreateDevcontainer),
|
||||
IsRestricted: optional.Some(form.Restricted),
|
||||
Visibility: optional.Some(form.Visibility),
|
||||
Language: optional.Some(form.Language),
|
||||
|
||||
329
routers/web/devcontainer/devcontainer.go
Normal file
329
routers/web/devcontainer/devcontainer.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
devcontainer_service "code.gitea.io/gitea/services/devcontainer"
|
||||
)
|
||||
|
||||
const (
|
||||
tplGetDevContainerDetails templates.TplName = "repo/devcontainer/details"
|
||||
)
|
||||
|
||||
// 获取仓库 Dev Container 详细信息
|
||||
// GET /{username}/{reponame}/devcontainer
|
||||
func GetDevContainerDetails(ctx *context.Context) {
|
||||
if ctx.Doer == nil {
|
||||
ctx.HTML(http.StatusForbidden, "")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
log.Info("setting.CustomConf %s", setting.CustomConf)
|
||||
log.Info("cfg.Section().Key().Value() %s", cfg.Section("server").Key("ROOT_URL").Value())
|
||||
if err != nil {
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
|
||||
ctx.Data["isAdmin"], err = devcontainer_service.IsAdmin(ctx, ctx.Doer, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
ctx.Data["HasDevContainer"], err = devcontainer_service.HasDevContainer(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
ctx.Data["ValidateDevContainerConfiguration"] = true
|
||||
ctx.Data["HasDevContainerConfiguration"], err = devcontainer_service.HasDevContainerConfiguration(ctx, ctx.Repo)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
ctx.Data["ValidateDevContainerConfiguration"] = false
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
if ctx.Data["HasDevContainerConfiguration"] == false {
|
||||
ctx.Data["ValidateDevContainerConfiguration"] = false
|
||||
}
|
||||
|
||||
ctx.Data["HasDevContainerDockerfile"], err = devcontainer_service.HasDevContainerDockerFile(ctx, ctx.Repo)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
if ctx.Data["HasDevContainer"] == true {
|
||||
configurationString, _ := devcontainer_service.GetDevcontainerConfigurationString(ctx, ctx.Repo.Repository)
|
||||
configurationModel, _ := devcontainer_service.UnmarshalDevcontainerConfigContent(configurationString)
|
||||
imageName := configurationModel.Image
|
||||
registry, namespace, repo, tag := devcontainer_service.ParseImageName(imageName)
|
||||
log.Info("%v %v", repo, tag)
|
||||
ctx.Data["RepositoryAddress"] = registry
|
||||
ctx.Data["RepositoryUsername"] = namespace
|
||||
ctx.Data["ImageName"] = "dev-" + ctx.Repo.Repository.Name + ":latest"
|
||||
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
// 获取WebSSH服务端口
|
||||
webTerminalURL, err := devcontainer_service.GetWebTerminalURL(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
} else {
|
||||
ctx.Data["WebSSHUrl"] = webTerminalURL
|
||||
}
|
||||
} else {
|
||||
webTerminalContainerName := cfg.Section("devcontainer").Key("WEB_TERMINAL_CONTAINER").Value()
|
||||
isWebTerminalNotFound, err := devcontainer_service.IsContainerNotFound(ctx, webTerminalContainerName)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
var webTerminalStatus string
|
||||
if !isWebTerminalNotFound {
|
||||
webTerminalStatus, err = devcontainer_service.GetDevContainerStatusFromDocker(ctx, webTerminalContainerName)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
}
|
||||
|
||||
if webTerminalContainerName == "" || isWebTerminalNotFound {
|
||||
ctx.Flash.Error("webTerminal do not exist. creating ....", true)
|
||||
err = devcontainer_service.RegistWebTerminal(ctx)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
ctx.Redirect(path.Join(ctx.Repo.RepoLink, "/devcontainer"))
|
||||
} else if webTerminalStatus != "running" && webTerminalStatus != "restarting" {
|
||||
err = devcontainer_service.DeleteDevContainerByDocker(ctx, webTerminalContainerName)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
err = devcontainer_service.RegistWebTerminal(ctx)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
ctx.Redirect(path.Join(ctx.Repo.RepoLink, "/devcontainer"))
|
||||
} else {
|
||||
|
||||
rootPort, err := devcontainer_service.GetPortFromURL(cfg.Section("server").Key("ROOT_URL").Value())
|
||||
if err != nil {
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
terminalParams := "user=" +
|
||||
ctx.Doer.Name +
|
||||
"&repo=" +
|
||||
ctx.Repo.Repository.Name +
|
||||
"&repoid=" +
|
||||
strconv.FormatInt(ctx.Repo.Repository.ID, 10) +
|
||||
"&userid=" +
|
||||
strconv.FormatInt(ctx.Doer.ID, 10) +
|
||||
"&domain=" +
|
||||
cfg.Section("server").Key("DOMAIN").Value() +
|
||||
"&port=" +
|
||||
rootPort
|
||||
port, err := devcontainer_service.GetMappedPort(ctx, webTerminalContainerName, "7681")
|
||||
webTerminalURL, err := devcontainer_service.ReplacePortOfUrl(cfg.Section("server").Key("ROOT_URL").Value(), fmt.Sprintf("%d", port))
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
ctx.Data["WebSSHUrl"] = webTerminalURL + "?type=docker&" + terminalParams
|
||||
}
|
||||
|
||||
}
|
||||
terminalURL, err := devcontainer_service.Get_IDE_TerminalURL(ctx, ctx.Doer, ctx.Repo)
|
||||
if err == nil {
|
||||
ctx.Data["VSCodeUrl"] = "vscode" + terminalURL
|
||||
ctx.Data["CursorUrl"] = "cursor" + terminalURL
|
||||
ctx.Data["WindsurfUrl"] = "windsurf" + terminalURL
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 携带数据渲染页面,返回
|
||||
ctx.Data["Title"] = ctx.Locale.Tr("repo.dev_container")
|
||||
ctx.Data["PageIsDevContainer"] = true
|
||||
ctx.Data["Repository"] = ctx.Repo.Repository
|
||||
ctx.Data["CreateDevcontainerSettingUrl"] = "/" + ctx.ContextUser.Name + "/" + ctx.Repo.Repository.Name + "/devcontainer/createConfiguration"
|
||||
ctx.Data["EditDevcontainerConfigurationUrl"] = ctx.Repo.RepoLink + "/_edit/" + ctx.Repo.Repository.DefaultBranch + "/.devcontainer/devcontainer.json"
|
||||
ctx.Data["TreeNames"] = []string{".devcontainer", "devcontainer.json"}
|
||||
ctx.Data["TreePaths"] = []string{".devcontainer", ".devcontainer/devcontainer.json"}
|
||||
ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src"
|
||||
ctx.Data["SaveMethods"] = []string{"Container", "DockerFile"}
|
||||
ctx.Data["SaveMethod"] = "Container"
|
||||
ctx.HTML(http.StatusOK, tplGetDevContainerDetails)
|
||||
}
|
||||
func GetDevContainerStatus(ctx *context.Context) {
|
||||
// 设置 CORS 响应头
|
||||
ctx.Resp.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
ctx.Resp.Header().Set("Access-Control-Allow-Methods", "*")
|
||||
ctx.Resp.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
var userID string
|
||||
if ctx.Doer != nil {
|
||||
userID = fmt.Sprintf("%d", ctx.Doer.ID)
|
||||
} else {
|
||||
query := ctx.Req.URL.Query()
|
||||
userID = query.Get("user")
|
||||
}
|
||||
realTimeStatus, err := devcontainer_service.GetDevContainerStatus(ctx, userID, fmt.Sprintf("%d", ctx.Repo.Repository.ID))
|
||||
if err != nil {
|
||||
log.Info("%v\n", err)
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]string{"status": realTimeStatus})
|
||||
}
|
||||
func CreateDevContainerConfiguration(ctx *context.Context) {
|
||||
hasDevContainerConfiguration, err := devcontainer_service.HasDevContainerConfiguration(ctx, ctx.Repo)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
return
|
||||
}
|
||||
if hasDevContainerConfiguration {
|
||||
ctx.Flash.Error("Already exist", true)
|
||||
return
|
||||
}
|
||||
isAdmin, err := devcontainer_service.IsAdmin(ctx, ctx.Doer, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
return
|
||||
}
|
||||
if !isAdmin {
|
||||
ctx.Flash.Error("permisson denied", true)
|
||||
return
|
||||
}
|
||||
err = devcontainer_service.CreateDevcontainerConfiguration(ctx.Repo.Repository, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(path.Join(ctx.Repo.RepoLink, "/devcontainer"))
|
||||
}
|
||||
func CreateDevContainer(ctx *context.Context) {
|
||||
hasDevContainer, err := devcontainer_service.HasDevContainer(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
return
|
||||
}
|
||||
if hasDevContainer {
|
||||
ctx.Flash.Error("Already exist", true)
|
||||
return
|
||||
}
|
||||
err = devcontainer_service.CreateDevcontainerAPIService(ctx, ctx.Repo.Repository, ctx.Doer, []string{}, true)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
ctx.Redirect(path.Join(ctx.Repo.RepoLink, "/devcontainer"))
|
||||
}
|
||||
func DeleteDevContainer(ctx *context.Context) {
|
||||
hasDevContainer, err := devcontainer_service.HasDevContainer(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
return
|
||||
}
|
||||
if !hasDevContainer {
|
||||
ctx.Flash.Error("Already Deleted.", true)
|
||||
return
|
||||
}
|
||||
err = devcontainer_service.DeleteDevContainer(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
ctx.Redirect(path.Join(ctx.Repo.RepoLink, "/devcontainer"))
|
||||
}
|
||||
func RestartDevContainer(ctx *context.Context) {
|
||||
hasDevContainer, err := devcontainer_service.HasDevContainer(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
return
|
||||
}
|
||||
if !hasDevContainer {
|
||||
log.Info(err.Error())
|
||||
ctx.Flash.Error("Already delete", true)
|
||||
return
|
||||
}
|
||||
err = devcontainer_service.RestartDevContainer(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
ctx.JSON(http.StatusOK, "")
|
||||
}
|
||||
func StopDevContainer(ctx *context.Context) {
|
||||
hasDevContainer, err := devcontainer_service.HasDevContainer(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
return
|
||||
}
|
||||
if !hasDevContainer {
|
||||
ctx.Flash.Error("Already delete", true)
|
||||
return
|
||||
}
|
||||
err = devcontainer_service.StopDevContainer(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(err.Error(), true)
|
||||
}
|
||||
ctx.JSON(http.StatusOK, "")
|
||||
}
|
||||
func UpdateDevContainer(ctx *context.Context) {
|
||||
hasDevContainer, err := devcontainer_service.HasDevContainer(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
ctx.JSON(http.StatusOK, map[string]string{"message": err.Error()})
|
||||
return
|
||||
}
|
||||
if !hasDevContainer {
|
||||
ctx.JSON(http.StatusOK, map[string]string{"message": "Already delete"})
|
||||
return
|
||||
}
|
||||
// 取得参数
|
||||
body, _ := io.ReadAll(ctx.Req.Body)
|
||||
var updateInfo devcontainer_service.UpdateInfo
|
||||
err = json.Unmarshal(body, &updateInfo)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusOK, map[string]string{"message": err.Error()})
|
||||
return
|
||||
}
|
||||
err = devcontainer_service.UpdateDevContainer(ctx, ctx.Doer, ctx.Repo.Repository, &updateInfo)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusOK, map[string]string{"message": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, map[string]string{"redirect": ctx.Repo.RepoLink + "/devcontainer", "message": "成功"})
|
||||
}
|
||||
func GetTerminalCommand(ctx *context.Context) {
|
||||
// 设置 CORS 响应头
|
||||
ctx.Resp.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
ctx.Resp.Header().Set("Access-Control-Allow-Methods", "*")
|
||||
ctx.Resp.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
query := ctx.Req.URL.Query()
|
||||
cmd, status, err := devcontainer_service.GetTerminalCommand(ctx, query.Get("user"), ctx.Repo.Repository)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
status = "error"
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]string{"command": cmd, "status": status})
|
||||
}
|
||||
|
||||
func GetDevContainerOutput(ctx *context.Context) {
|
||||
// 设置 CORS 响应头
|
||||
ctx.Resp.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
ctx.Resp.Header().Set("Access-Control-Allow-Methods", "*")
|
||||
ctx.Resp.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
output, err := devcontainer_service.GetDevContainerOutput(ctx, ctx.Doer, ctx.Repo.Repository)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
}
|
||||
ctx.JSON(http.StatusOK, output)
|
||||
}
|
||||
21
routers/web/devcontainer/devstar_home.go
Normal file
21
routers/web/devcontainer/devstar_home.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
gitea_web_context "code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
// TplDevstarHome 显示 DevStar Home 页面 templates/vscode-home.tmpl
|
||||
TplDevstarHome templates.TplName = "repo/devcontainer/vscode-home"
|
||||
)
|
||||
|
||||
// DevstarHome 渲染适配于 VSCode 插件的 DevStar Home 页面
|
||||
func DevstarHome(ctx *gitea_web_context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("home")
|
||||
ctx.Resp.Header().Del("X-Frame-Options")
|
||||
//ctx.Resp.Header().Set("Content-Security-Policy", "frame-ancestors *")
|
||||
ctx.HTML(http.StatusOK, TplDevstarHome)
|
||||
}
|
||||
@@ -23,9 +23,11 @@ import (
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/modules/web/middleware"
|
||||
"code.gitea.io/gitea/modules/web/routing"
|
||||
devcontainer_api "code.gitea.io/gitea/routers/api/devcontainer"
|
||||
"code.gitea.io/gitea/routers/common"
|
||||
"code.gitea.io/gitea/routers/web/admin"
|
||||
"code.gitea.io/gitea/routers/web/auth"
|
||||
devcontainer_web "code.gitea.io/gitea/routers/web/devcontainer"
|
||||
"code.gitea.io/gitea/routers/web/devtest"
|
||||
"code.gitea.io/gitea/routers/web/events"
|
||||
"code.gitea.io/gitea/routers/web/explore"
|
||||
@@ -865,7 +867,6 @@ func registerWebRoutes(m *web.Router) {
|
||||
reqUnitPullsReader := context.RequireUnitReader(unit.TypePullRequests)
|
||||
reqUnitWikiReader := context.RequireUnitReader(unit.TypeWiki)
|
||||
reqUnitWikiWriter := context.RequireUnitWriter(unit.TypeWiki)
|
||||
|
||||
reqPackageAccess := func(accessMode perm.AccessMode) func(ctx *context.Context) {
|
||||
return func(ctx *context.Context) {
|
||||
if ctx.Package.AccessMode < accessMode && !ctx.IsUserSiteAdmin() {
|
||||
@@ -1390,6 +1391,45 @@ func registerWebRoutes(m *web.Router) {
|
||||
}, reqSignIn, context.RepoAssignment, reqUnitCodeReader)
|
||||
// end "/{username}/{reponame}": repo code
|
||||
|
||||
m.Group("/{username}/{reponame}/devcontainer", func() { // repo Dev Container
|
||||
m.Group("", func() {
|
||||
m.Get("", devcontainer_web.GetDevContainerDetails)
|
||||
m.Get("/createConfiguration", devcontainer_web.CreateDevContainerConfiguration)
|
||||
m.Get("/create", devcontainer_web.CreateDevContainer, context.RepoMustNotBeArchived()) // 仓库状态非 Archived 才可以创建 DevContainer
|
||||
m.Post("/delete", devcontainer_web.DeleteDevContainer)
|
||||
m.Get("/restart", devcontainer_web.RestartDevContainer)
|
||||
m.Get("/stop", devcontainer_web.StopDevContainer)
|
||||
m.Post("/update", devcontainer_web.UpdateDevContainer)
|
||||
},
|
||||
// 已登录
|
||||
context.RequireDevcontainerAccess, reqSignIn)
|
||||
m.Get("/status", devcontainer_web.GetDevContainerStatus)
|
||||
m.Get("/command", devcontainer_web.GetTerminalCommand)
|
||||
m.Get("/output", devcontainer_web.GetDevContainerOutput)
|
||||
},
|
||||
// 解析仓库信息
|
||||
// 具有code读取权限
|
||||
context.RepoAssignment, reqUnitCodeReader,
|
||||
)
|
||||
m.Get("/devstar-home", devcontainer_web.DevstarHome)
|
||||
m.Group("/api/devcontainer", func() {
|
||||
// 获取 某用户在某仓库中的 DevContainer 细节(包括SSH连接信息),默认不会等待 (wait = false)
|
||||
// 请求方式: GET /api/devcontainer?repoId=${repoId}&wait=true // 无需传入 userId,直接从 token 中提取
|
||||
m.Get("", devcontainer_api.GetDevcontainer)
|
||||
|
||||
// 抽象方法,创建 某用户在某仓库中的 DevContainer
|
||||
// 请求方式: POST /api/devcontainer
|
||||
// 请求体数据: { repoId: REPO_ID }
|
||||
m.Post("", web.Bind(forms.CreateRepoDevcontainerForm{}), devcontainer_api.CreateRepoDevcontainer)
|
||||
|
||||
// 删除某用户在某仓库中的 DevContainer
|
||||
// 请求方法: DELETE /api/devcontainer?repoId=${repoId}
|
||||
m.Delete("", devcontainer_api.DeleteRepoDevcontainer)
|
||||
|
||||
// 列举某用户已创建的所有 DevContainer
|
||||
m.Get("/user", devcontainer_api.ListUserDevcontainers)
|
||||
|
||||
})
|
||||
m.Group("/{username}/{reponame}", func() { // repo tags
|
||||
m.Group("/tags", func() {
|
||||
m.Get("", context.RepoRefByDefaultBranch() /* for the "commits" tab */, repo.TagsList)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
@@ -396,6 +397,26 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) {
|
||||
return
|
||||
}
|
||||
ctx.Data["Permission"] = &ctx.Repo.Permission
|
||||
if ctx.Doer != nil {
|
||||
ctx.Data["AllowCreateDevcontainer"] = ctx.Doer.AllowCreateDevcontainer
|
||||
} else {
|
||||
query := ctx.Req.URL.Query()
|
||||
userID := query.Get("user")
|
||||
userNum, err := strconv.ParseInt(userID, 10, 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
u, err := user_model.GetUserByID(ctx, userNum)
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/users")
|
||||
} else {
|
||||
ctx.ServerError("GetUserByID", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.Data["AllowCreateDevcontainer"] = u.AllowCreateDevcontainer
|
||||
}
|
||||
|
||||
if repo.IsMirror {
|
||||
pullMirror, err := repo_model.GetMirrorByRepoID(ctx, repo.ID)
|
||||
@@ -412,6 +433,18 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) {
|
||||
ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty
|
||||
}
|
||||
|
||||
func RequireDevcontainerAccess(ctx *Context) {
|
||||
if ctx.Doer == nil {
|
||||
ctx.HTTPError(http.StatusUnauthorized, "Devcontainer access requires login")
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.Doer.CanCreateDevcontainer() {
|
||||
ctx.HTTPError(http.StatusForbidden, "User cannot create devcontainers")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// RepoAssignment returns a middleware to handle repository assignment
|
||||
func RepoAssignment(ctx *Context) {
|
||||
if ctx.Data["Repository"] != nil {
|
||||
|
||||
893
services/devcontainer/devcontainer.go
Normal file
893
services/devcontainer/devcontainer.go
Normal file
@@ -0,0 +1,893 @@
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
devcontainer_models "code.gitea.io/gitea/models/devcontainer"
|
||||
"code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/user"
|
||||
docker_module "code.gitea.io/gitea/modules/docker"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
gitea_context "code.gitea.io/gitea/services/context"
|
||||
files_service "code.gitea.io/gitea/services/repository/files"
|
||||
"github.com/docker/docker/api/types"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
func HasDevContainer(ctx context.Context, userID, repoID int64) (bool, error) {
|
||||
var hasDevContainer bool
|
||||
dbEngine := db.GetEngine(ctx)
|
||||
hasDevContainer, err := dbEngine.
|
||||
Table("devcontainer").
|
||||
Select("*").
|
||||
Where("user_id = ? AND repo_id = ?", userID, repoID).
|
||||
Exist()
|
||||
if err != nil {
|
||||
return hasDevContainer, err
|
||||
}
|
||||
return hasDevContainer, nil
|
||||
}
|
||||
func HasDevContainerConfiguration(ctx context.Context, repo *gitea_context.Repository) (bool, error) {
|
||||
_, err := FileExists(".devcontainer/devcontainer.json", repo)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
configurationString, err := GetDevcontainerConfigurationString(ctx, repo.Repository)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
configurationModel, err := UnmarshalDevcontainerConfigContent(configurationString)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
// 执行验证
|
||||
if errs := configurationModel.Validate(); len(errs) > 0 {
|
||||
log.Info("配置验证失败:")
|
||||
for _, err := range errs {
|
||||
fmt.Printf(" - %s\n", err.Error())
|
||||
}
|
||||
return true, fmt.Errorf("配置格式错误")
|
||||
} else {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
func HasDevContainerDockerFile(ctx context.Context, repo *gitea_context.Repository) (bool, error) {
|
||||
_, err := FileExists(".devcontainer/devcontainer.json", repo)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
configurationString, err := GetDevcontainerConfigurationString(ctx, repo.Repository)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
configurationModel, err := UnmarshalDevcontainerConfigContent(configurationString)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 执行验证
|
||||
if errs := configurationModel.Validate(); len(errs) > 0 {
|
||||
log.Info("配置验证失败:")
|
||||
for _, err := range errs {
|
||||
fmt.Printf(" - %s\n", err.Error())
|
||||
}
|
||||
return false, fmt.Errorf("配置格式错误")
|
||||
} else {
|
||||
log.Info("%v", configurationModel)
|
||||
if configurationModel.Build == nil || configurationModel.Build.Dockerfile == "" {
|
||||
return false, nil
|
||||
}
|
||||
_, err := FileExists(".devcontainer/"+configurationModel.Build.Dockerfile, repo)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
func CreateDevcontainerConfiguration(repo *repo.Repository, doer *user.User) error {
|
||||
jsonString := `{
|
||||
"name":"template",
|
||||
"image":"mcr.microsoft.com/devcontainers/base:dev-ubuntu-20.04",
|
||||
"forwardPorts": ["8080"],
|
||||
"containerEnv": {
|
||||
"NODE_ENV": "development"
|
||||
},
|
||||
"initializeCommand": "echo \"init\";",
|
||||
"postCreateCommand": [
|
||||
"echo \"created\"",
|
||||
"echo \"test\""
|
||||
],
|
||||
"runArgs": [
|
||||
"-p 8888"
|
||||
]
|
||||
}`
|
||||
_, err := files_service.ChangeRepoFiles(db.DefaultContext, repo, doer, &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "create",
|
||||
TreePath: ".devcontainer/devcontainer.json",
|
||||
ContentReader: bytes.NewReader([]byte(jsonString)),
|
||||
},
|
||||
},
|
||||
OldBranch: "main",
|
||||
NewBranch: "main",
|
||||
Message: "add container configuration",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetWebTerminalURL(ctx context.Context, userID, repoID int64) (string, error) {
|
||||
var devcontainerName string
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dbEngine := db.GetEngine(ctx)
|
||||
_, err = dbEngine.
|
||||
Table("devcontainer").
|
||||
Select("name").
|
||||
Where("user_id = ? AND repo_id = ?", userID, repoID).
|
||||
Get(&devcontainerName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
//k8s的逻辑
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
/*
|
||||
-1不存在
|
||||
0 已创建数据库记录
|
||||
1 正在拉取镜像
|
||||
2 正在创建和启动容器
|
||||
3 容器安装必要工具
|
||||
4 容器正在运行
|
||||
5 正在提交容器更新
|
||||
6 正在重启
|
||||
7 正在停止
|
||||
8 容器已停止
|
||||
9 正在删除
|
||||
10已删除
|
||||
*/
|
||||
func GetDevContainerStatus(ctx context.Context, userID, repoID string) (string, error) {
|
||||
var id int
|
||||
var containerName string
|
||||
|
||||
var status uint16
|
||||
var realTimeStatus uint16
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dbEngine := db.GetEngine(ctx)
|
||||
_, err = dbEngine.
|
||||
Table("devcontainer").
|
||||
Select("devcontainer_status, id, name").
|
||||
Where("user_id = ? AND repo_id = ?", userID, repoID).
|
||||
Get(&status, &id, &containerName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if id == 0 {
|
||||
return fmt.Sprintf("%d", -1), nil
|
||||
}
|
||||
|
||||
realTimeStatus = status
|
||||
switch status {
|
||||
//正在重启
|
||||
case 6:
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
//k8s的逻辑
|
||||
} else {
|
||||
containerRealTimeStatus, err := GetDevContainerStatusFromDocker(ctx, containerName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
} else if containerRealTimeStatus == "running" {
|
||||
realTimeStatus = 4
|
||||
}
|
||||
}
|
||||
break
|
||||
//正在关闭
|
||||
case 7:
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
//k8s的逻辑
|
||||
} else {
|
||||
containerRealTimeStatus, err := GetDevContainerStatusFromDocker(ctx, containerName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
} else if containerRealTimeStatus == "exited" {
|
||||
realTimeStatus = 8
|
||||
} else {
|
||||
err = StopDevContainerByDocker(ctx, containerName)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
break
|
||||
case 9:
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
//k8s的逻辑
|
||||
} else {
|
||||
isContainerNotFound, err := IsContainerNotFound(ctx, containerName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
} else if isContainerNotFound {
|
||||
realTimeStatus = 10
|
||||
} else {
|
||||
err = DeleteDevContainerByDocker(ctx, containerName)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
break
|
||||
default:
|
||||
log.Info("other status")
|
||||
}
|
||||
//状态更新
|
||||
if realTimeStatus != status {
|
||||
if realTimeStatus == 10 {
|
||||
_, err = dbEngine.Table("devcontainer").
|
||||
Where("user_id = ? AND repo_id = ? ", userID, repoID).
|
||||
Delete()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = dbEngine.Table("devcontainer_output").
|
||||
Where("user_id = ? AND repo_id = ? ", userID, repoID).
|
||||
Delete()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "-1", nil
|
||||
}
|
||||
_, err = dbEngine.Table("devcontainer").
|
||||
Where("user_id = ? AND repo_id = ? ", userID, repoID).
|
||||
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: realTimeStatus})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = dbEngine.Table("devcontainer_output").
|
||||
Where("user_id = ? AND repo_id = ? AND list_id = ?", userID, repoID, status).
|
||||
Update(&devcontainer_models.DevcontainerOutput{Status: "finished"})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%d", realTimeStatus), nil
|
||||
}
|
||||
func CreateDevContainer(ctx context.Context, repo *repo.Repository, doer *user.User, publicKeyList []string, isWebTerminal bool) error {
|
||||
containerName := getSanitizedDevcontainerName(doer.Name, repo.Name)
|
||||
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unixTimestamp := time.Now().Unix()
|
||||
newDevcontainer := devcontainer_models.Devcontainer{
|
||||
Name: containerName,
|
||||
DevcontainerHost: cfg.Section("server").Key("DOMAIN").Value(),
|
||||
DevcontainerUsername: "root",
|
||||
DevcontainerWorkDir: "/workspace",
|
||||
DevcontainerStatus: 0,
|
||||
RepoId: repo.ID,
|
||||
UserId: doer.ID,
|
||||
CreatedUnix: unixTimestamp,
|
||||
UpdatedUnix: unixTimestamp,
|
||||
}
|
||||
|
||||
dbEngine := db.GetEngine(ctx)
|
||||
|
||||
_, err = dbEngine.
|
||||
Table("devcontainer").
|
||||
Insert(newDevcontainer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dbEngine.
|
||||
Table("devcontainer").
|
||||
Select("*").
|
||||
Where("user_id = ? AND repo_id = ?", doer.ID, repo.ID).
|
||||
Get(&newDevcontainer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
otherCtx := context.Background()
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
//k8s的逻辑
|
||||
} else {
|
||||
imageName, err := CreateDevContainerByDockerCommand(otherCtx, &newDevcontainer, repo, publicKeyList)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !isWebTerminal {
|
||||
CreateDevContainerByDockerAPI(otherCtx, &newDevcontainer, imageName, repo, publicKeyList)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
func DeleteDevContainer(ctx context.Context, userID, repoID int64) error {
|
||||
dbEngine := db.GetEngine(ctx)
|
||||
var devContainerInfo devcontainer_models.Devcontainer
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dbEngine.
|
||||
Table("devcontainer").
|
||||
Select("*").
|
||||
Where("user_id = ? AND repo_id = ?", userID, repoID).
|
||||
Get(&devContainerInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dbEngine.Table("devcontainer").
|
||||
Where("user_id = ? AND repo_id = ? ", userID, repoID).
|
||||
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: 9})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
otherCtx := context.Background()
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
//k8s的逻辑
|
||||
} else {
|
||||
|
||||
err = DeleteDevContainerByDocker(otherCtx, devContainerInfo.Name)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
func RestartDevContainer(ctx context.Context, userID, repoID int64) error {
|
||||
dbEngine := db.GetEngine(ctx)
|
||||
var devContainerInfo devcontainer_models.Devcontainer
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dbEngine.
|
||||
Table("devcontainer").
|
||||
Select("*").
|
||||
Where("user_id = ? AND repo_id = ?", userID, repoID).
|
||||
Get(&devContainerInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dbEngine.Table("devcontainer").
|
||||
Where("user_id = ? AND repo_id = ? ", userID, repoID).
|
||||
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: 6})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
otherCtx := context.Background()
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
//k8s的逻辑
|
||||
} else {
|
||||
err = RestartDevContainerByDocker(otherCtx, devContainerInfo.Name)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
func StopDevContainer(ctx context.Context, userID, repoID int64) error {
|
||||
dbEngine := db.GetEngine(ctx)
|
||||
var devContainerInfo devcontainer_models.Devcontainer
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dbEngine.
|
||||
Table("devcontainer").
|
||||
Select("*").
|
||||
Where("user_id = ? AND repo_id = ?", userID, repoID).
|
||||
Get(&devContainerInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dbEngine.Table("devcontainer").
|
||||
Where("user_id = ? AND repo_id = ? ", userID, repoID).
|
||||
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: 7})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
otherCtx := context.Background()
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
//k8s的逻辑
|
||||
} else {
|
||||
err = StopDevContainerByDocker(otherCtx, devContainerInfo.Name)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateDevContainer(ctx context.Context, doer *user.User, repo *repo.Repository, updateInfo *UpdateInfo) error {
|
||||
dbEngine := db.GetEngine(ctx)
|
||||
var devContainerInfo devcontainer_models.Devcontainer
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dbEngine.
|
||||
Table("devcontainer").
|
||||
Select("*").
|
||||
Where("user_id = ? AND repo_id = ?", doer.ID, repo.ID).
|
||||
Get(&devContainerInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dbEngine.Table("devcontainer").
|
||||
Where("user_id = ? AND repo_id = ? ", doer.ID, repo.ID).
|
||||
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: 5})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
otherCtx := context.Background()
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
//k8s的逻辑
|
||||
} else {
|
||||
updateErr := UpdateDevContainerByDocker(otherCtx, &devContainerInfo, updateInfo, repo, doer)
|
||||
_, err = dbEngine.Table("devcontainer").
|
||||
Where("user_id = ? AND repo_id = ? ", doer.ID, repo.ID).
|
||||
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: 4})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
func GetTerminalCommand(ctx context.Context, userID string, repo *repo.Repository) (string, string, error) {
|
||||
|
||||
dbEngine := db.GetEngine(ctx)
|
||||
var devContainerInfo devcontainer_models.Devcontainer
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
_, err = dbEngine.
|
||||
Table("devcontainer").
|
||||
Select("*").
|
||||
Where("user_id = ? AND repo_id = ?", userID, repo.ID).
|
||||
Get(&devContainerInfo)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
realTimeStatus := devContainerInfo.DevcontainerStatus
|
||||
var cmd string
|
||||
|
||||
switch devContainerInfo.DevcontainerStatus {
|
||||
case 0:
|
||||
if devContainerInfo.Id > 0 {
|
||||
realTimeStatus = 1
|
||||
}
|
||||
break
|
||||
case 1:
|
||||
//正在拉取镜像,当镜像拉取成功,则状态转移
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
//k8s的逻辑
|
||||
} else {
|
||||
configurationString, err := GetDevcontainerConfigurationString(ctx, repo)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
configurationModel, err := UnmarshalDevcontainerConfigContent(configurationString)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var imageName string
|
||||
if configurationModel.Build == nil || configurationModel.Build.Dockerfile == "" {
|
||||
imageName = configurationModel.Image
|
||||
} else {
|
||||
imageName = userID + "-" + fmt.Sprintf("%d", repo.ID) + "-dockerfile"
|
||||
}
|
||||
isExist, err := ImageExists(ctx, imageName)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if isExist {
|
||||
realTimeStatus = 2
|
||||
} else {
|
||||
_, err = dbEngine.Table("devcontainer_output").
|
||||
Select("command").
|
||||
Where("user_id = ? AND repo_id = ? AND list_id = ?", userID, repo.ID, realTimeStatus).
|
||||
Get(&cmd)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
break
|
||||
case 2:
|
||||
//正在创建容器,创建容器成功,则状态转移
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
//k8s的逻辑
|
||||
} else {
|
||||
status, err := GetDevContainerStatusFromDocker(ctx, devContainerInfo.Name)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if status == "running" {
|
||||
//添加脚本文件
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
|
||||
} else {
|
||||
|
||||
var scriptContent []byte
|
||||
_, err = os.Stat("webTerminal.sh")
|
||||
if os.IsNotExist(err) {
|
||||
_, err = os.Stat("/app/gitea/webTerminal.sh")
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", err
|
||||
} else {
|
||||
scriptContent, err = os.ReadFile("/app/gitea/webTerminal.sh")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
scriptContent, err = os.ReadFile("webTerminal.sh")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
// 创建 tar 归档文件
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
defer tw.Close()
|
||||
|
||||
// 添加文件到 tar 归档
|
||||
AddFileToTar(tw, "webTerminal.sh", string(scriptContent), 0777)
|
||||
// 创建 Docker 客户端
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// 获取容器 ID
|
||||
containerID, err := docker_module.GetContainerID(cli, devContainerInfo.Name)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
err = cli.CopyToContainer(ctx, containerID, "/home", bytes.NewReader(buf.Bytes()), types.CopyToContainerOptions{})
|
||||
if err != nil {
|
||||
log.Info("%v", err)
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
realTimeStatus = 3
|
||||
}
|
||||
}
|
||||
break
|
||||
case 3:
|
||||
//正在初始化容器,初始化容器成功,则状态转移
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
//k8s的逻辑
|
||||
} else {
|
||||
|
||||
status, err := CheckDirExistsFromDocker(ctx, devContainerInfo.Name, devContainerInfo.DevcontainerWorkDir)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if status {
|
||||
realTimeStatus = 4
|
||||
}
|
||||
}
|
||||
break
|
||||
case 4:
|
||||
//正在连接容器
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
//k8s的逻辑
|
||||
} else {
|
||||
_, err = dbEngine.Table("devcontainer_output").
|
||||
Select("command").
|
||||
Where("user_id = ? AND repo_id = ? AND list_id = ?", userID, repo.ID, realTimeStatus).
|
||||
Get(&cmd)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if realTimeStatus != devContainerInfo.DevcontainerStatus {
|
||||
//下一条指令
|
||||
_, err = dbEngine.Table("devcontainer_output").
|
||||
Select("command").
|
||||
Where("user_id = ? AND repo_id = ? AND list_id = ?", userID, repo.ID, realTimeStatus).
|
||||
Get(&cmd)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
_, err = dbEngine.Table("devcontainer").
|
||||
Where("user_id = ? AND repo_id = ? ", userID, repo.ID).
|
||||
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: realTimeStatus})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
return cmd, fmt.Sprintf("%d", realTimeStatus), nil
|
||||
}
|
||||
func GetDevContainerOutput(ctx context.Context, doer *user.User, repo *repo.Repository) (OutputResponse, error) {
|
||||
var devContainerOutput []devcontainer_models.DevcontainerOutput
|
||||
dbEngine := db.GetEngine(ctx)
|
||||
resp := OutputResponse{}
|
||||
var status string
|
||||
var containerName string
|
||||
_, err := dbEngine.
|
||||
Table("devcontainer").
|
||||
Select("devcontainer_status, name").
|
||||
Where("user_id = ? AND repo_id = ?", doer.ID, repo.ID).
|
||||
Get(&status, &containerName)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
err = dbEngine.Table("devcontainer_output").
|
||||
Where("user_id = ? AND repo_id = ?", doer.ID, repo.ID).
|
||||
Find(&devContainerOutput)
|
||||
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
if len(devContainerOutput) > 0 {
|
||||
|
||||
resp.CurrentJob.Title = repo.Name + " Devcontainer Info"
|
||||
resp.CurrentJob.Detail = status
|
||||
if status == "4" {
|
||||
// 获取WebSSH服务端口
|
||||
webTerminalURL, err := GetWebTerminalURL(ctx, doer.ID, repo.ID)
|
||||
if err == nil {
|
||||
return resp, err
|
||||
}
|
||||
// 解析URL
|
||||
u, err := url.Parse(webTerminalURL)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
// 分离主机和端口
|
||||
terminalHost, terminalPort, err := net.SplitHostPort(u.Host)
|
||||
resp.CurrentJob.IP = terminalHost
|
||||
resp.CurrentJob.Port = terminalPort
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
}
|
||||
for _, item := range devContainerOutput {
|
||||
logLines := []ViewStepLogLine{}
|
||||
logLines = append(logLines, ViewStepLogLine{
|
||||
Index: 1,
|
||||
Message: item.Output,
|
||||
})
|
||||
resp.CurrentJob.Steps = append(resp.CurrentJob.Steps, &ViewJobStep{
|
||||
Summary: item.Command,
|
||||
Status: item.Status,
|
||||
Logs: logLines,
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
func GetMappedPort(ctx context.Context, containerName string, port string) (uint16, error) {
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
//k8s的逻辑
|
||||
return 0, nil
|
||||
} else {
|
||||
port, err := docker_module.GetMappedPort(ctx, containerName, port)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
}
|
||||
func GetDevcontainersList(ctx context.Context, doer *user.User, pageNum, pageSize int) (DevcontainerList, error) {
|
||||
|
||||
// 0. 构造异常返回时的空数据
|
||||
var resultDevContainerListVO = DevcontainerList{
|
||||
Page: 0,
|
||||
PageSize: 50,
|
||||
PageTotalNum: 0,
|
||||
ItemTotalNum: 0,
|
||||
DevContainers: []devcontainer_models.Devcontainer{},
|
||||
}
|
||||
resultDevContainerListVO.UserID = doer.ID
|
||||
resultDevContainerListVO.Username = doer.Name
|
||||
|
||||
paginationOption := db.ListOptions{
|
||||
Page: pageNum,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
|
||||
paginationOption.ListAll = false // 强制使用分页查询,禁止一次性列举所有 devContainers
|
||||
if paginationOption.Page <= 0 { // 未指定页码/无效页码:查询第 1 页
|
||||
paginationOption.Page = 1
|
||||
}
|
||||
if paginationOption.PageSize <= 0 || paginationOption.PageSize > 50 {
|
||||
paginationOption.PageSize = 50 // /无效页面大小/超过每页最大限制:自动调整到系统最大开发容器页面大小
|
||||
}
|
||||
resultDevContainerListVO.Page = paginationOption.Page
|
||||
resultDevContainerListVO.PageSize = paginationOption.PageSize
|
||||
|
||||
// 2. SQL 条件构建
|
||||
|
||||
sqlCondition := builder.Eq{"user_id": doer.ID}
|
||||
// 执行数据库事务
|
||||
err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
// 查询总数
|
||||
count, err := db.GetEngine(ctx).
|
||||
Table("devcontainer").
|
||||
Where(sqlCondition).
|
||||
Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resultDevContainerListVO.ItemTotalNum = count
|
||||
|
||||
// 无记录直接返回
|
||||
if count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 计算分页参数
|
||||
pageSize := int64(resultDevContainerListVO.PageSize)
|
||||
resultDevContainerListVO.PageTotalNum = int(math.Ceil(float64(count) / float64(pageSize)))
|
||||
|
||||
// 查询分页数据
|
||||
sess := db.GetEngine(ctx).
|
||||
Table("devcontainer").
|
||||
Join("INNER", "repository", "devcontainer.repo_id = repository.id").
|
||||
Where(sqlCondition).
|
||||
OrderBy("devcontainer_id DESC").
|
||||
Select(`devcontainer.id AS devcontainer_id,
|
||||
devcontainer.name AS devcontainer_name,
|
||||
devcontainer.devcontainer_host AS devcontainer_host,
|
||||
devcontainer.devcontainer_username AS devcontainer_username,
|
||||
devcontainer.devcontainer_work_dir AS devcontainer_work_dir,
|
||||
devcontainer.repo_id AS repo_id,
|
||||
repository.name AS repo_name,
|
||||
repository.owner_name AS repo_owner_name,
|
||||
repository.description AS repo_description,
|
||||
CONCAT('/', repository.owner_name, '/', repository.name) AS repo_link`)
|
||||
|
||||
resultDevContainerListVO.DevContainers = make([]devcontainer_models.Devcontainer, 0, pageSize)
|
||||
err = db.SetSessionPagination(sess, &paginationOption).
|
||||
Find(&resultDevContainerListVO.DevContainers)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return resultDevContainerListVO, err
|
||||
}
|
||||
|
||||
return resultDevContainerListVO, nil
|
||||
}
|
||||
func Get_IDE_TerminalURL(ctx *gitea_context.Context, doer *user.User, repo *gitea_context.Repository) (string, error) {
|
||||
dbEngine := db.GetEngine(ctx)
|
||||
var devContainerInfo devcontainer_models.Devcontainer
|
||||
_, err := dbEngine.
|
||||
Table("devcontainer").
|
||||
Select("*").
|
||||
Where("user_id = ? AND repo_id = ?", doer.ID, repo.Repository.ID).
|
||||
Get(&devContainerInfo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 加载配置文件
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
if err != nil {
|
||||
log.Error("Get_IDE_TerminalURL: 加载配置文件失败: %v", err)
|
||||
return "", err
|
||||
}
|
||||
log.Info("Get_IDE_TerminalURL: 配置文件加载成功, ROOT_URL=%s", cfg.Section("server").Key("ROOT_URL").Value())
|
||||
var access_token string
|
||||
|
||||
// 检查 session 中是否已存在 token
|
||||
if ctx.Session.Get("access_token") != nil {
|
||||
access_token = ctx.Session.Get("access_token").(string)
|
||||
} else {
|
||||
// 生成 token
|
||||
token := &auth_model.AccessToken{
|
||||
UID: devContainerInfo.UserId,
|
||||
Name: "terminal_login_token",
|
||||
}
|
||||
exist, err := auth_model.AccessTokenByNameExists(ctx, token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exist {
|
||||
db.GetEngine(ctx).Table("access_token").Where("uid = ? AND name = ?", doer.ID, "terminal_login_token").Delete()
|
||||
}
|
||||
scope, err := auth_model.AccessTokenScope(strings.Join([]string{"write:user", "write:repository"}, ",")).Normalize()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token.Scope = scope
|
||||
err = auth_model.NewAccessToken(db.DefaultContext, token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ctx.Session.Set("terminal_login_token", token.Token)
|
||||
access_token = token.Token
|
||||
}
|
||||
|
||||
// 根据不同的代理类型获取 SSH 端口
|
||||
var port string
|
||||
|
||||
if cfg.Section("k8s").Key("ENABLE").Value() == "true" {
|
||||
|
||||
} else {
|
||||
mappedPort, err := docker_module.GetMappedPort(ctx, devContainerInfo.Name, "22")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
port = fmt.Sprintf("%d", mappedPort)
|
||||
}
|
||||
|
||||
// 构建并返回 URL
|
||||
return "://mengning.devstar/" +
|
||||
"openProject?host=" + repo.Repository.Name +
|
||||
"&hostname=" + devContainerInfo.DevcontainerHost +
|
||||
"&port=" + port +
|
||||
"&username=" + doer.Name +
|
||||
"&path=" + devContainerInfo.DevcontainerWorkDir +
|
||||
"&access_token=" + access_token +
|
||||
"&devstar_username=" + repo.Repository.OwnerName +
|
||||
"&devstar_domain=" + cfg.Section("server").Key("ROOT_URL").Value(), nil
|
||||
}
|
||||
57
services/devcontainer/devcontainer_api.go
Normal file
57
services/devcontainer/devcontainer_api.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/user"
|
||||
)
|
||||
|
||||
func CreateDevcontainerAPIService(ctx context.Context, repo *repo.Repository, doer *user.User, publicKeyList []string, isWebTerminal bool) error {
|
||||
var userSSHPublicKeyList []string
|
||||
err := db.GetEngine(ctx).
|
||||
Table("public_key").
|
||||
Select("content").
|
||||
Where("owner_id = ?", doer.ID).
|
||||
Find(&userSSHPublicKeyList)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allPublicKeyList := append(userSSHPublicKeyList, publicKeyList...)
|
||||
return CreateDevContainer(ctx, repo, doer, allPublicKeyList, isWebTerminal)
|
||||
}
|
||||
|
||||
// OpenDevcontainerAPIService API 专用获取 DevContainer Service
|
||||
func OpenDevcontainerAPIService(ctx context.Context, userID, repoID int64) (*DevcontainerVO, error) {
|
||||
var devcontainerDetails DevcontainerVO
|
||||
dbEngine := db.GetEngine(ctx)
|
||||
// 2. 查询数据库
|
||||
_, err := dbEngine.
|
||||
Table("devcontainer").
|
||||
Select(""+
|
||||
"devcontainer.id AS devcontainer_id,"+
|
||||
"devcontainer.name AS devcontainer_name,"+
|
||||
"devcontainer.devcontainer_host AS devcontainer_host,"+
|
||||
"devcontainer.devcontainer_status AS devcontainer_status,"+
|
||||
"devcontainer.devcontainer_username AS devcontainer_username,"+
|
||||
"devcontainer.devcontainer_work_dir AS devcontainer_work_dir,"+
|
||||
"devcontainer.repo_id AS repo_id,"+
|
||||
"devcontainer.user_id AS user_id,"+
|
||||
"repository.name AS repo_name,"+
|
||||
"repository.owner_name AS repo_owner_name,"+
|
||||
"repository.description AS repo_description,"+
|
||||
"CONCAT('/', repository.owner_name, '/', repository.name) AS repo_link").
|
||||
Join("INNER", "repository", "devcontainer.repo_id = repository.id").
|
||||
Where("devcontainer.user_id = ? AND devcontainer.repo_id = ?", userID, repoID).
|
||||
Get(&devcontainerDetails)
|
||||
if err != nil {
|
||||
return &devcontainerDetails, err
|
||||
}
|
||||
// 2. 获取实时port
|
||||
devcontainerDetails.DevContainerPort, err = GetMappedPort(ctx, devcontainerDetails.DevContainerName, "22")
|
||||
if err != nil {
|
||||
return &devcontainerDetails, err
|
||||
}
|
||||
return &devcontainerDetails, nil
|
||||
}
|
||||
56
services/devcontainer/devcontainer_configuration.go
Normal file
56
services/devcontainer/devcontainer_configuration.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"regexp"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/user"
|
||||
files_service "code.gitea.io/gitea/services/repository/files"
|
||||
)
|
||||
|
||||
func GetDevcontainerConfigurationString(ctx context.Context, repo *repo.Repository) (string, error) {
|
||||
configuration, err := GetFileContentByPath(ctx, repo, ".devcontainer/devcontainer.json")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cleanedContent, err := removeComments(configuration)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cleanedContent, nil
|
||||
}
|
||||
|
||||
// 移除 JSON 文件中的注释
|
||||
func removeComments(data string) (string, error) {
|
||||
// 移除单行注释 // ...
|
||||
re := regexp.MustCompile(`//.*`)
|
||||
data = re.ReplaceAllString(data, "")
|
||||
|
||||
// 移除多行注释 /* ... */
|
||||
re = regexp.MustCompile(`/\*.*?\*/`)
|
||||
data = re.ReplaceAllString(data, "")
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func UpdateDevcontainerConfiguration(newContent string, repo *repo.Repository, doer *user.User) error {
|
||||
// 更新devcontainer.json配置文件
|
||||
_, err := files_service.ChangeRepoFiles(db.DefaultContext, repo, doer, &files_service.ChangeRepoFilesOptions{
|
||||
Files: []*files_service.ChangeRepoFile{
|
||||
{
|
||||
Operation: "update",
|
||||
TreePath: ".devcontainer/devcontainer.json",
|
||||
ContentReader: bytes.NewReader([]byte(newContent)),
|
||||
},
|
||||
},
|
||||
OldBranch: repo.DefaultBranch,
|
||||
Message: "Update container",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
865
services/devcontainer/devcontainer_configuration_type.go
Normal file
865
services/devcontainer/devcontainer_configuration_type.go
Normal file
@@ -0,0 +1,865 @@
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DevContainerConfiguration 表示 devcontainer.json 的根配置
|
||||
type DevContainerConfiguration struct {
|
||||
// ----------------------------
|
||||
// 通用属性 (General properties)
|
||||
// ----------------------------
|
||||
Name string `json:"name,omitempty"` // 容器名称
|
||||
ForwardPorts []interface{} `json:"forwardPorts,omitempty"` // 要转发的端口(整数或字符串)
|
||||
PortsAttributes map[string]PortAttribute `json:"portsAttributes,omitempty"` // 端口特性配置
|
||||
OtherPortsAttributes map[string]PortAttribute `json:"otherPortsAttributes,omitempty"` // 其他端口特性配置
|
||||
ContainerEnv map[string]string `json:"containerEnv,omitempty"` // 容器环境变量
|
||||
RemoteEnv map[string]string `json:"remoteEnv,omitempty"` // 远程环境变量
|
||||
RemoteUser string `json:"remoteUser,omitempty"` // 远程用户
|
||||
ContainerUser string `json:"containerUser,omitempty"` // 容器用户
|
||||
UpdateRemoteUserUID bool `json:"updateRemoteUserUID,omitempty"` // 是否更新远程用户UID
|
||||
UserEnvProbe string `json:"userEnvProbe,omitempty"` // 用户环境探测方式(枚举值)
|
||||
OverrideCommand bool `json:"overrideCommand,omitempty"` // 是否覆盖默认命令
|
||||
ShutdownAction string `json:"shutdownAction,omitempty"` // 关闭动作(枚举值)
|
||||
Init bool `json:"init,omitempty"` // 是否使用init进程
|
||||
Privileged bool `json:"privileged,omitempty"` // 是否特权模式
|
||||
CapAdd []string `json:"capAdd,omitempty"` // 添加的Linux能力
|
||||
SecurityOpt []string `json:"securityOpt,omitempty"` // 安全选项
|
||||
Mounts []interface{} `json:"mounts,omitempty"` // 挂载配置(字符串或对象)
|
||||
Features map[string]json.RawMessage `json:"features,omitempty"` // 功能特性配置
|
||||
OverrideFeatureInstallOrder []string `json:"overrideFeatureInstallOrder,omitempty"` // 特性安装顺序覆盖
|
||||
Customizations map[string]interface{} `json:"customizations,omitempty"` // 自定义配置
|
||||
|
||||
// --------------------------------------
|
||||
// 镜像/Dockerfile 相关属性 (Image/Dockerfile)
|
||||
// --------------------------------------
|
||||
Image string `json:"image,omitempty"` // 使用的镜像
|
||||
Build *Build `json:"build,omitempty"` // Docker构建配置
|
||||
AppPort interface{} `json:"appPort,omitempty"` // 应用端口(整数/字符串/数组)
|
||||
WorkspaceMount string `json:"workspaceMount,omitempty"` // 工作区挂载点
|
||||
WorkspaceFolder string `json:"workspaceFolder,omitempty"` // 工作区目录
|
||||
RunArgs []string `json:"runArgs,omitempty"` // 容器运行参数
|
||||
|
||||
// ----------------------------------
|
||||
// Docker Compose 相关属性 (Docker Compose)
|
||||
// ----------------------------------
|
||||
DockerComposeFile StringOrArray `json:"dockerComposeFile,omitempty"` // Compose文件路径(字符串或数组)
|
||||
Service string `json:"service,omitempty"` // 使用的服务名
|
||||
RunServices []string `json:"runServices,omitempty"` // 要运行的服务
|
||||
|
||||
// ---------------------------
|
||||
// 生命周期命令 (Lifecycle commands)
|
||||
// ---------------------------
|
||||
InitializeCommand interface{} `json:"initializeCommand,omitempty"` // 初始化命令(字符串/数组/对象)
|
||||
OnCreateCommand interface{} `json:"onCreateCommand,omitempty"` // 创建后命令(字符串/数组/对象)
|
||||
UpdateContentCommand interface{} `json:"updateContentCommand,omitempty"` // 内容更新命令(字符串/数组/对象)
|
||||
PostCreateCommand interface{} `json:"postCreateCommand,omitempty"` // 创建完成命令(字符串/数组/对象)
|
||||
PostStartCommand interface{} `json:"postStartCommand,omitempty"` // 启动后命令(字符串/数组/对象)
|
||||
PostAttachCommand interface{} `json:"postAttachCommand,omitempty"` // 连接后命令(字符串/数组/对象)
|
||||
WaitFor string `json:"waitFor,omitempty"` // 等待事件(枚举值)
|
||||
|
||||
// ---------------------------
|
||||
// 主机要求 (Host requirements)
|
||||
// ---------------------------
|
||||
HostRequirements *HostRequirements `json:"hostRequirements,omitempty"` // 主机资源要求
|
||||
}
|
||||
|
||||
// PortAttribute 端口特性配置
|
||||
type PortAttribute struct {
|
||||
Label string `json:"label,omitempty"` // 端口标签
|
||||
Protocol string `json:"protocol,omitempty"` // 协议类型(枚举值)
|
||||
OnAutoForward string `json:"onAutoForward,omitempty"` // 自动转发行为(枚举值)
|
||||
RequireLocalPort bool `json:"requireLocalPort,omitempty"` // 是否需要本地端口
|
||||
ElevateIfNeeded bool `json:"elevateIfNeeded,omitempty"` // 需要时是否提权
|
||||
}
|
||||
|
||||
// Build Docker构建配置
|
||||
type Build struct {
|
||||
Dockerfile string `json:"dockerfile,omitempty"` // Dockerfile路径
|
||||
Context string `json:"context,omitempty"` // 构建上下文
|
||||
Args map[string]string `json:"args,omitempty"` // 构建参数
|
||||
Options []string `json:"options,omitempty"` // 构建选项
|
||||
Target string `json:"target,omitempty"` // 构建目标阶段
|
||||
CacheFrom StringOrArray `json:"cacheFrom,omitempty"` // 缓存来源(字符串或数组)
|
||||
}
|
||||
|
||||
// HostRequirements 主机资源要求
|
||||
type HostRequirements struct {
|
||||
CPUs int `json:"cpus,omitempty"` // CPU核心数
|
||||
Memory string `json:"memory,omitempty"` // 内存要求(如"8GB")
|
||||
Storage string `json:"storage,omitempty"` // 存储要求(如"100GB")
|
||||
GPU interface{} `json:"gpu,omitempty"` // GPU要求(布尔/字符串/对象)
|
||||
}
|
||||
|
||||
// StringOrArray 处理字符串或数组类型的自定义类型
|
||||
type StringOrArray []string
|
||||
|
||||
// MarshalJSON 自定义JSON序列化(单个值时输出字符串)
|
||||
func (s StringOrArray) MarshalJSON() ([]byte, error) {
|
||||
if len(s) == 1 {
|
||||
return json.Marshal(s[0])
|
||||
}
|
||||
return json.Marshal([]string(s))
|
||||
}
|
||||
|
||||
// UnmarshalJSON 自定义JSON反序列化
|
||||
func (s *StringOrArray) UnmarshalJSON(data []byte) error {
|
||||
// 尝试解析为字符串
|
||||
var single string
|
||||
if err := json.Unmarshal(data, &single); err == nil {
|
||||
*s = []string{single}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 尝试解析为字符串数组
|
||||
var multi []string
|
||||
if err := json.Unmarshal(data, &multi); err == nil {
|
||||
*s = multi
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, &multi)
|
||||
}
|
||||
|
||||
// Unmarshal 反序列化JSON
|
||||
func UnmarshalDevcontainerConfigContent(devcontainerConfigContent string) (*DevContainerConfiguration, error) {
|
||||
// 1. 初始化
|
||||
devcontainerObject := &DevContainerConfiguration{}
|
||||
// 2. JSON 反序列化
|
||||
err := json.Unmarshal([]byte(devcontainerConfigContent), devcontainerObject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 3. 返回
|
||||
return devcontainerObject, err
|
||||
}
|
||||
|
||||
// 定义允许的枚举值集合
|
||||
var (
|
||||
validUserEnvProbe = []string{"none", "interactiveShell", "loginShell"}
|
||||
validShutdownActions = []string{"none", "stopContainer"}
|
||||
validPortProtocols = []string{"http", "https", "none"}
|
||||
validOnAutoForwardOpts = []string{"notify", "openBrowser", "openPreview", "ignore", "silent"}
|
||||
validWaitForEvents = []string{"init", "create", "attach"}
|
||||
)
|
||||
|
||||
// Validate 验证 DevContainerConfiguration 的合法性
|
||||
func (c *DevContainerConfiguration) Validate() []error {
|
||||
var errors []error
|
||||
|
||||
// 1. 基本字段验证
|
||||
errors = append(errors, c.validateBasicFields()...)
|
||||
|
||||
// 2. 端口相关验证
|
||||
errors = append(errors, c.validatePorts()...)
|
||||
|
||||
// 3. 构建配置验证
|
||||
errors = append(errors, c.validateBuildConfig()...)
|
||||
|
||||
// 4. Docker Compose 配置验证
|
||||
errors = append(errors, c.validateDockerCompose()...)
|
||||
|
||||
// 5. 生命周期命令验证
|
||||
errors = append(errors, c.validateLifecycleCommands()...)
|
||||
|
||||
// 6. 主机要求验证
|
||||
errors = append(errors, c.validateHostRequirements()...)
|
||||
|
||||
// 7. 互斥字段验证
|
||||
errors = append(errors, c.validateMutuallyExclusive()...)
|
||||
// 8. RunArgs字段验证
|
||||
errors = append(errors, c.ValidateRunArgs()...)
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// 验证基本字段
|
||||
func (c *DevContainerConfiguration) validateBasicFields() []error {
|
||||
var errors []error
|
||||
|
||||
// 名称验证
|
||||
if c.Name == "" {
|
||||
errors = append(errors, fmt.Errorf("name is required"))
|
||||
} else if len(c.Name) > 100 {
|
||||
errors = append(errors, fmt.Errorf("name exceeds maximum length of 100 characters"))
|
||||
}
|
||||
|
||||
// 枚举值验证
|
||||
errors = append(errors, validateEnum("userEnvProbe", c.UserEnvProbe, validUserEnvProbe)...)
|
||||
errors = append(errors, validateEnum("shutdownAction", c.ShutdownAction, validShutdownActions)...)
|
||||
|
||||
// 用户ID更新验证
|
||||
if c.UpdateRemoteUserUID && (c.RemoteUser == "" && c.ContainerUser == "") {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("updateRemoteUserUID requires either remoteUser or containerUser to be set"))
|
||||
}
|
||||
|
||||
// 挂载点验证
|
||||
for i, mount := range c.Mounts {
|
||||
switch m := mount.(type) {
|
||||
case string:
|
||||
if !isValidMountString(m) {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("mount[%d]: invalid mount format '%s'", i, m))
|
||||
}
|
||||
case map[string]interface{}:
|
||||
// 更详细的对象挂载验证
|
||||
if _, ok := m["source"]; !ok {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("mount[%d]: missing 'source' property", i))
|
||||
}
|
||||
if _, ok := m["target"]; !ok {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("mount[%d]: missing 'target' property", i))
|
||||
}
|
||||
default:
|
||||
errors = append(errors,
|
||||
fmt.Errorf("mount[%d]: must be string or object", i))
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// 验证端口相关配置
|
||||
func (c *DevContainerConfiguration) validatePorts() []error {
|
||||
var errors []error
|
||||
|
||||
// 转发端口格式验证
|
||||
for i, port := range c.ForwardPorts {
|
||||
switch p := port.(type) {
|
||||
case int:
|
||||
if p <= 0 || p > 65535 {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("forwardPorts[%d]: port number %d out of range", i, p))
|
||||
}
|
||||
case string:
|
||||
if !isValidPortSpec(p) {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("forwardPorts[%d]: invalid port specification '%s'", i, p))
|
||||
}
|
||||
default:
|
||||
errors = append(errors,
|
||||
fmt.Errorf("forwardPorts[%d]: must be integer or string", i))
|
||||
}
|
||||
}
|
||||
|
||||
// 应用端口格式验证
|
||||
if c.AppPort != nil {
|
||||
switch appPort := c.AppPort.(type) {
|
||||
case int:
|
||||
if appPort <= 0 || appPort > 65535 {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("appPort: port number %d out of range", appPort))
|
||||
}
|
||||
case string:
|
||||
if !isValidPortSpec(appPort) {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("appPort: invalid port specification '%s'", appPort))
|
||||
}
|
||||
case []interface{}:
|
||||
for i, port := range appPort {
|
||||
switch p := port.(type) {
|
||||
case int:
|
||||
if p <= 0 || p > 65535 {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("appPort[%d]: port number %d out of range", i, p))
|
||||
}
|
||||
case string:
|
||||
if !isValidPortSpec(p) {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("appPort[%d]: invalid port specification '%s'", i, p))
|
||||
}
|
||||
default:
|
||||
errors = append(errors,
|
||||
fmt.Errorf("appPort[%d]: must be integer or string", i))
|
||||
}
|
||||
}
|
||||
default:
|
||||
errors = append(errors,
|
||||
fmt.Errorf("appPort: must be integer, string, or array of port specifications"))
|
||||
}
|
||||
}
|
||||
|
||||
// 端口属性验证
|
||||
validatePortAttributes := func(attrs map[string]PortAttribute, prefix string) {
|
||||
for port, attr := range attrs {
|
||||
// 验证端口格式
|
||||
if !isValidPortSpec(port) {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("%s: key '%s' is not a valid port specification", prefix, port))
|
||||
}
|
||||
|
||||
// 验证属性
|
||||
errors = append(errors,
|
||||
validateEnum(prefix+"."+port+".protocol", attr.Protocol, validPortProtocols)...)
|
||||
errors = append(errors,
|
||||
validateEnum(prefix+"."+port+".onAutoForward", attr.OnAutoForward, validOnAutoForwardOpts)...)
|
||||
}
|
||||
}
|
||||
|
||||
validatePortAttributes(c.PortsAttributes, "portsAttributes")
|
||||
validatePortAttributes(c.OtherPortsAttributes, "otherPortsAttributes")
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// 验证构建配置
|
||||
func (c *DevContainerConfiguration) validateBuildConfig() []error {
|
||||
var errors []error
|
||||
|
||||
if c.Build != nil {
|
||||
// Dockerfile 路径验证
|
||||
if c.Build.Dockerfile == "" {
|
||||
errors = append(errors, fmt.Errorf("build.dockerfile is required when using build configuration"))
|
||||
}
|
||||
|
||||
// 构建目标验证
|
||||
if c.Build.Target != "" && !isValidIdentifier(c.Build.Target) {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("build.target: invalid target name '%s'", c.Build.Target))
|
||||
}
|
||||
|
||||
// 构建参数验证
|
||||
for argName := range c.Build.Args {
|
||||
if !isValidEnvVarName(argName) {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("build.args: invalid argument name '%s'", argName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// 验证 Docker Compose 配置
|
||||
func (c *DevContainerConfiguration) validateDockerCompose() []error {
|
||||
var errors []error
|
||||
|
||||
if len(c.DockerComposeFile) > 0 {
|
||||
// 服务名验证
|
||||
if c.Service == "" {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("service is required when using dockerComposeFile"))
|
||||
} else if !isValidServiceName(c.Service) {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("service: invalid service name '%s'", c.Service))
|
||||
}
|
||||
|
||||
// 运行服务验证
|
||||
for i, svc := range c.RunServices {
|
||||
if !isValidServiceName(svc) {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("runServices[%d]: invalid service name '%s'", i, svc))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// 验证生命周期命令
|
||||
func (c *DevContainerConfiguration) validateLifecycleCommands() []error {
|
||||
var errors []error
|
||||
|
||||
validateCommand := func(name string, cmd interface{}) {
|
||||
switch cmd.(type) {
|
||||
case nil:
|
||||
// 允许为空
|
||||
case string:
|
||||
// 空字符串视为有效
|
||||
case []interface{}:
|
||||
// 验证数组元素都是字符串
|
||||
arr := cmd.([]interface{})
|
||||
for i, item := range arr {
|
||||
if _, ok := item.(string); !ok {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("%s[%d]: must be string", name, i))
|
||||
}
|
||||
}
|
||||
case map[string]interface{}:
|
||||
// 验证命令对象结构
|
||||
cmdObj := cmd.(map[string]interface{})
|
||||
if _, ok := cmdObj["command"]; !ok {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("%s: command object requires 'command' property", name))
|
||||
}
|
||||
default:
|
||||
errors = append(errors,
|
||||
fmt.Errorf("%s: must be string, array of strings, or command object", name))
|
||||
}
|
||||
}
|
||||
|
||||
validateCommand("initializeCommand", c.InitializeCommand)
|
||||
validateCommand("onCreateCommand", c.OnCreateCommand)
|
||||
validateCommand("updateContentCommand", c.UpdateContentCommand)
|
||||
validateCommand("postCreateCommand", c.PostCreateCommand)
|
||||
validateCommand("postStartCommand", c.PostStartCommand)
|
||||
validateCommand("postAttachCommand", c.PostAttachCommand)
|
||||
|
||||
// 等待事件验证
|
||||
errors = append(errors, validateEnum("waitFor", c.WaitFor, validWaitForEvents)...)
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// 验证主机要求
|
||||
func (c *DevContainerConfiguration) validateHostRequirements() []error {
|
||||
var errors []error
|
||||
|
||||
if c.HostRequirements != nil {
|
||||
// CPU 验证
|
||||
if c.HostRequirements.CPUs < 0 {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("hostRequirements.cpus: must be positive integer"))
|
||||
}
|
||||
|
||||
// 内存格式验证
|
||||
if c.HostRequirements.Memory != "" {
|
||||
if !isValidMemorySpec(c.HostRequirements.Memory) {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("hostRequirements.memory: invalid format '%s'", c.HostRequirements.Memory))
|
||||
}
|
||||
}
|
||||
|
||||
// 存储格式验证
|
||||
if c.HostRequirements.Storage != "" {
|
||||
if !isValidStorageSpec(c.HostRequirements.Storage) {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("hostRequirements.storage: invalid format '%s'", c.HostRequirements.Storage))
|
||||
}
|
||||
}
|
||||
|
||||
// GPU 验证
|
||||
switch gpu := c.HostRequirements.GPU.(type) {
|
||||
case bool:
|
||||
// 布尔值有效
|
||||
case string:
|
||||
if !isValidGPUDriver(gpu) {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("hostRequirements.gpu: unsupported driver '%s'", gpu))
|
||||
}
|
||||
case map[string]interface{}:
|
||||
// 验证 GPU 对象结构
|
||||
if _, ok := gpu["count"]; !ok {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("hostRequirements.gpu: object requires 'count' property"))
|
||||
}
|
||||
case nil:
|
||||
// 允许为空
|
||||
default:
|
||||
errors = append(errors,
|
||||
fmt.Errorf("hostRequirements.gpu: must be boolean, string, or object"))
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// 验证互斥字段
|
||||
func (c *DevContainerConfiguration) validateMutuallyExclusive() []error {
|
||||
var errors []error
|
||||
|
||||
// 镜像和构建配置互斥
|
||||
if c.Image != "" && c.Build != nil {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("cannot specify both 'image' and 'build' properties"))
|
||||
}
|
||||
|
||||
// Docker Compose 和镜像/构建互斥
|
||||
if len(c.DockerComposeFile) > 0 && (c.Image != "" || c.Build != nil) {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("cannot specify 'dockerComposeFile' with 'image' or 'build'"))
|
||||
}
|
||||
|
||||
// 工作区挂载和工作区文件夹共存验证
|
||||
if c.WorkspaceMount != "" && c.WorkspaceFolder != "" {
|
||||
errors = append(errors,
|
||||
fmt.Errorf("'workspaceMount' and 'workspaceFolder' should not be used together"))
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// ================ 辅助验证函数 ================
|
||||
|
||||
// 验证枚举值
|
||||
func validateEnum(fieldName, value string, validValues []string) []error {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, valid := range validValues {
|
||||
if value == valid {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return []error{fmt.Errorf("%s: invalid value '%s', valid options: %v",
|
||||
fieldName, value, validValues)}
|
||||
}
|
||||
|
||||
// 验证端口规范格式 (支持 "8080" 或 "8080:80")
|
||||
func isValidPortSpec(spec string) bool {
|
||||
parts := strings.Split(spec, ":")
|
||||
if len(parts) > 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, part := range parts {
|
||||
port, err := strconv.Atoi(part)
|
||||
if err != nil || port <= 0 || port > 65535 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 验证挂载字符串格式 (支持 "source:target" 或 "source:target:ro")
|
||||
func isValidMountString(mount string) bool {
|
||||
parts := strings.Split(mount, ":")
|
||||
if len(parts) < 2 || len(parts) > 3 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 验证选项(如果存在)
|
||||
if len(parts) == 3 {
|
||||
options := strings.Split(parts[2], ",")
|
||||
for _, opt := range options {
|
||||
switch opt {
|
||||
case "ro", "rw", "consistent", "cached", "delegated":
|
||||
// 有效选项
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 验证环境变量名格式
|
||||
func isValidEnvVarName(name string) bool {
|
||||
match, _ := regexp.MatchString(`^[a-zA-Z_][a-zA-Z0-9_]*$`, name)
|
||||
return match
|
||||
}
|
||||
|
||||
// 验证服务名格式
|
||||
func isValidServiceName(name string) bool {
|
||||
match, _ := regexp.MatchString(`^[a-z0-9][a-z0-9_-]*$`, name)
|
||||
return match
|
||||
}
|
||||
|
||||
// 验证内存规格格式 (如 "4G", "512M")
|
||||
func isValidMemorySpec(mem string) bool {
|
||||
re := regexp.MustCompile(`^\d+[KMGTP]?B?$`)
|
||||
return re.MatchString(strings.ToUpper(mem))
|
||||
}
|
||||
|
||||
// 验证存储规格格式 (如 "100GB", "512MB")
|
||||
func isValidStorageSpec(storage string) bool {
|
||||
re := regexp.MustCompile(`^\d+[KMGTP]B$`)
|
||||
return re.MatchString(strings.ToUpper(storage))
|
||||
}
|
||||
|
||||
// 验证GPU驱动名
|
||||
func isValidGPUDriver(driver string) bool {
|
||||
validDrivers := []string{"nvidia", "amd", "intel"}
|
||||
for _, d := range validDrivers {
|
||||
if strings.EqualFold(driver, d) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isValidIdentifier 验证 Docker 构建目标名称的合法性
|
||||
// Docker 目标名称必须遵循以下规则:
|
||||
// 1. 只能包含小写字母、数字、下划线、连字符和点号
|
||||
// 2. 必须以字母或数字开头
|
||||
// 3. 不能超过 128 个字符
|
||||
func isValidIdentifier(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 长度限制 (Docker 规范)
|
||||
if len(name) > 128 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查首字符
|
||||
firstChar := name[0]
|
||||
if !((firstChar >= 'a' && firstChar <= 'z') ||
|
||||
(firstChar >= '0' && firstChar <= '9')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查所有字符
|
||||
for _, r := range name {
|
||||
if !(r >= 'a' && r <= 'z') &&
|
||||
!(r >= '0' && r <= '9') &&
|
||||
r != '_' &&
|
||||
r != '-' &&
|
||||
r != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 禁止的特殊序列
|
||||
forbiddenSequences := []string{"..", "__", "--", ".-", "-."}
|
||||
for _, seq := range forbiddenSequences {
|
||||
if strings.Contains(name, seq) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ExtractContainerPorts 从 DevContainerConfiguration 的 ForwardPorts 中提取容器端口
|
||||
func (c *DevContainerConfiguration) ExtractContainerPorts() []int {
|
||||
var containerPorts []int
|
||||
|
||||
for _, port := range c.ForwardPorts {
|
||||
switch p := port.(type) {
|
||||
case int:
|
||||
// 如果是整数,既是主机端口也是容器端口
|
||||
if p > 0 && p <= 65535 {
|
||||
containerPorts = append(containerPorts, p)
|
||||
}
|
||||
case string:
|
||||
// 解析端口映射格式 "hostPort:containerPort" 或单一端口
|
||||
parts := strings.Split(p, ":")
|
||||
if len(parts) == 1 {
|
||||
// 单一端口,既是主机端口也是容器端口
|
||||
if portNum, err := strconv.Atoi(parts[0]); err == nil && portNum > 0 && portNum <= 65535 {
|
||||
containerPorts = append(containerPorts, portNum)
|
||||
}
|
||||
} else if len(parts) == 2 {
|
||||
// 端口映射格式 "hostPort:containerPort",取容器端口(第二个部分)
|
||||
if containerPort, err := strconv.Atoi(parts[1]); err == nil && containerPort > 0 && containerPort <= 65535 {
|
||||
containerPorts = append(containerPorts, containerPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return containerPorts
|
||||
}
|
||||
|
||||
// GetAllMountOptions 获取所有挂载选项的详细信息
|
||||
func (c *DevContainerConfiguration) GetAllMountOptions() []map[string]interface{} {
|
||||
var mountOptions []map[string]interface{}
|
||||
|
||||
for _, mount := range c.Mounts {
|
||||
switch m := mount.(type) {
|
||||
case string:
|
||||
// 对于字符串格式,简单解析
|
||||
parts := strings.Split(m, ":")
|
||||
option := map[string]interface{}{
|
||||
"source": parts[0],
|
||||
"target": parts[1],
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
option["options"] = parts[2]
|
||||
}
|
||||
mountOptions = append(mountOptions, option)
|
||||
case map[string]interface{}:
|
||||
mountOptions = append(mountOptions, m)
|
||||
}
|
||||
}
|
||||
|
||||
return mountOptions
|
||||
}
|
||||
|
||||
// ExtractMountFlags 将 Mounts 字段转换为 -v 参数字符串列表
|
||||
func (c *DevContainerConfiguration) ExtractMountFlags() []string {
|
||||
var mountFlags []string
|
||||
|
||||
// 遍历 Mounts 列表中的每个挂载项
|
||||
for _, mount := range c.Mounts {
|
||||
switch m := mount.(type) {
|
||||
case string:
|
||||
// 字符串格式的挂载,直接转换为 -v 格式
|
||||
mountFlags = append(mountFlags, fmt.Sprintf("-v %s", m))
|
||||
case map[string]interface{}:
|
||||
// 对象格式的挂载,需要解析并转换
|
||||
if flag := convertMountObjectToFlag(m); flag != "" {
|
||||
mountFlags = append(mountFlags, fmt.Sprintf("-v %s", flag))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mountFlags
|
||||
}
|
||||
|
||||
// convertMountObjectToFlag 将挂载对象转换为 -v 标志字符串
|
||||
func convertMountObjectToFlag(mount map[string]interface{}) string {
|
||||
// 提取必需的 source 和 target 字段
|
||||
source, sourceOk := mount["source"].(string)
|
||||
target, targetOk := mount["target"].(string)
|
||||
|
||||
if !sourceOk || !targetOk {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 构建基本挂载字符串
|
||||
mountStr := fmt.Sprintf("%s:%s", source, target)
|
||||
|
||||
// 添加可选的只读标志
|
||||
if readOnly, ok := mount["readOnly"].(bool); ok && readOnly {
|
||||
mountStr = fmt.Sprintf("%s:ro", mountStr)
|
||||
}
|
||||
|
||||
// 添加可选的 consistency 字段(如果存在)
|
||||
if consistency, ok := mount["consistency"].(string); ok {
|
||||
switch consistency {
|
||||
case "consistent", "cached", "delegated":
|
||||
mountStr = fmt.Sprintf("%s:%s", mountStr, consistency)
|
||||
}
|
||||
}
|
||||
|
||||
return mountStr
|
||||
}
|
||||
|
||||
// ValidateRunArgs 验证 RunArgs 字段中的参数是否有效
|
||||
func (c *DevContainerConfiguration) ValidateRunArgs() []error {
|
||||
var errors []error
|
||||
|
||||
// 常见的有效 Docker 运行参数前缀
|
||||
validArgPrefixes := []string{
|
||||
"--network=", "--net=",
|
||||
"--ipc=",
|
||||
"--pid=",
|
||||
"--user=", "-u ",
|
||||
"--group-add=",
|
||||
"--ulimit=",
|
||||
"--sysctl=",
|
||||
"--tmpfs=",
|
||||
"--volume=", "-v ",
|
||||
"--mount=",
|
||||
"--device=",
|
||||
"--cap-add=",
|
||||
"--cap-drop=",
|
||||
"--security-opt=",
|
||||
"--env=", "-e ",
|
||||
"--env-file=",
|
||||
"--hostname=", "-h ",
|
||||
"--domainname=",
|
||||
"--mac-address=",
|
||||
"--memory=", "-m ",
|
||||
"--memory-swap=",
|
||||
"--memory-swappiness=",
|
||||
"--oom-kill-disable",
|
||||
"--oom-score-adj=",
|
||||
"--restart=",
|
||||
"--cpu-shares=",
|
||||
"--cpu-period=",
|
||||
"--cpu-quota=",
|
||||
"--cpuset-cpus=",
|
||||
"--cpuset-mems=",
|
||||
"--blkio-weight=",
|
||||
"--blkio-weight-device=",
|
||||
"--device-read-bps=",
|
||||
"--device-write-bps=",
|
||||
"--device-read-iops=",
|
||||
"--device-write-iops=",
|
||||
"--kernel-memory=",
|
||||
"--memory-reservation=",
|
||||
"--pids-limit=",
|
||||
"--publish=", "-p ",
|
||||
"--publish-all=", "-P",
|
||||
"--expose=",
|
||||
"--link=",
|
||||
"--dns=",
|
||||
"--dns-option=",
|
||||
"--dns-search=",
|
||||
"--add-host=",
|
||||
"--cgroup-parent=",
|
||||
"--init",
|
||||
"--init-path=",
|
||||
"--isolation=",
|
||||
"--label=", "-l ",
|
||||
"--label-file=",
|
||||
"--log-driver=",
|
||||
"--log-opt=",
|
||||
"--name=",
|
||||
"--workdir=", "-w ",
|
||||
}
|
||||
|
||||
for i, arg := range c.RunArgs {
|
||||
// 检查参数是否以有效的前缀开头
|
||||
isValid := false
|
||||
for _, prefix := range validArgPrefixes {
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
isValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 一些独立的标志参数
|
||||
standaloneFlags := []string{
|
||||
"--init", "-i", "-t", "-it", "--privileged",
|
||||
"--read-only", "--rm", "--sig-proxy", "--tty",
|
||||
"--interactive", "--detach", "-d",
|
||||
}
|
||||
|
||||
for _, flag := range standaloneFlags {
|
||||
if arg == flag {
|
||||
isValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isValid && !strings.HasPrefix(arg, "-") {
|
||||
// 可能是值而不是参数,这取决于上下文
|
||||
// 这里我们允许非参数开头的值
|
||||
isValid = true
|
||||
}
|
||||
|
||||
if !isValid {
|
||||
errors = append(errors, fmt.Errorf("runArgs[%d]: invalid docker argument '%s'", i, arg))
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// parseCommand 解析不同格式的命令
|
||||
func (c *DevContainerConfiguration) ParseCommand(command interface{}) []string {
|
||||
var commands []string
|
||||
|
||||
switch cmd := command.(type) {
|
||||
case string:
|
||||
if cmd != "" {
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
case []interface{}:
|
||||
var cmdParts []string
|
||||
for _, part := range cmd {
|
||||
if str, ok := part.(string); ok {
|
||||
cmdParts = append(cmdParts, str)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if len(cmdParts) > 0 {
|
||||
commands = cmdParts
|
||||
}
|
||||
case map[string]interface{}:
|
||||
// 处理对象格式的命令
|
||||
if commandField, ok := cmd["command"]; ok {
|
||||
subCommands := c.ParseCommand(commandField)
|
||||
commands = append(commands, subCommands...)
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
58
services/devcontainer/devcontainer_type.go
Normal file
58
services/devcontainer/devcontainer_type.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package devcontainer
|
||||
|
||||
import devcontainer_models "code.gitea.io/gitea/models/devcontainer"
|
||||
|
||||
type UpdateInfo struct {
|
||||
ImageName string `json:"ImageName"`
|
||||
PassWord string `json:"RepositoryPassword"`
|
||||
RepositoryAddress string `json:"RepositoryAddress"`
|
||||
RepositoryUsername string `json:"RepositoryUsername"`
|
||||
SaveMethod string `json:"SaveMethod"`
|
||||
}
|
||||
type OutputResponse struct {
|
||||
CurrentJob struct {
|
||||
IP string `json:"ip"`
|
||||
Port string `json:"port"`
|
||||
Title string `json:"title"`
|
||||
Detail string `json:"detail"`
|
||||
Steps []*ViewJobStep `json:"steps"`
|
||||
} `json:"currentDevcontainer"`
|
||||
}
|
||||
type ViewJobStep struct {
|
||||
Summary string `json:"summary"`
|
||||
Duration string `json:"duration"`
|
||||
Status string `json:"status"`
|
||||
Logs []ViewStepLogLine `json:"logs"`
|
||||
}
|
||||
|
||||
type ViewStepLogLine struct {
|
||||
Index int64 `json:"index"`
|
||||
Message string `json:"message"`
|
||||
Timestamp float64 `json:"timestamp"`
|
||||
}
|
||||
type DevcontainerList struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
DevContainers []devcontainer_models.Devcontainer `json:"devContainers"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
PageTotalNum int `json:"pageTotalNum"`
|
||||
ItemTotalNum int64 `json:"itemTotalNum"`
|
||||
}
|
||||
|
||||
type DevcontainerVO struct {
|
||||
DevContainerId int64 `json:"devContainerId" xorm:"devcontainer_id"`
|
||||
DevContainerName string `json:"devContainerName" xorm:"devcontainer_name"`
|
||||
DevContainerHost string `json:"devContainerHost" xorm:"devcontainer_host"`
|
||||
DevContainerUsername string `json:"devContainerUsername" xorm:"devcontainer_username"`
|
||||
DevContainerWorkDir string `json:"devContainerWorkDir" xorm:"devcontainer_work_dir"`
|
||||
UserId int64 `json:"userId" xorm:"user_id"`
|
||||
RepoId int64 `json:"repoId" xorm:"repo_id"`
|
||||
RepoName string `json:"repoName" xorm:"repo_name"`
|
||||
//RepoOwnerID int64 `json:"repo_owner_id" xorm:"repo_owner_id"`
|
||||
RepoOwnerName string `json:"repo_owner_name" xorm:"repo_owner_name"`
|
||||
RepoLink string `json:"repo_link" xorm:"repo_link"`
|
||||
RepoDescription string `json:"repoDescription,omitempty" xorm:"repo_description"`
|
||||
DevContainerPort uint16 `json:"devContainerPort,omitempty"`
|
||||
DevContainerStatus uint16 `json:"devContainerStatus,omitempty" xorm:"devcontainer_status"`
|
||||
}
|
||||
235
services/devcontainer/devcontainer_utils.go
Normal file
235
services/devcontainer/devcontainer_utils.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/perm"
|
||||
"code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/charset"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/typesniffer"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
gitea_context "code.gitea.io/gitea/services/context"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func IsAdmin(ctx context.Context, doer *user.User, repoID int64) (bool, error) {
|
||||
if doer.IsAdmin {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
e := db.GetEngine(ctx)
|
||||
teamMember, err := e.Table("team_user").
|
||||
Join("INNER", "team_repo", "`team_repo`.team_id = `team_user`.team_id").
|
||||
Join("INNER", "team_unit", "`team_unit`.team_id = `team_user`.team_id").
|
||||
Where("`team_repo`.repo_id = ? AND `team_unit`.access_mode = ? ",
|
||||
repoID, perm.AccessModeAdmin).
|
||||
And("team_user.uid = ?", doer.ID).Exist()
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
if teamMember {
|
||||
return true, nil
|
||||
}
|
||||
return e.Get(&repo.Collaboration{RepoID: repoID, UserID: doer.ID, Mode: 3})
|
||||
}
|
||||
|
||||
func GetFileContentByPath(ctx context.Context, repo *repo.Repository, path string) (string, error) {
|
||||
var err error
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 1. 获取默认分支名
|
||||
branchName := repo.DefaultBranch
|
||||
if len(branchName) == 0 {
|
||||
branchName = cfg.Section("repository").Key("DEFAULT_BRANCH").Value()
|
||||
}
|
||||
|
||||
// 2. 打开默认分支
|
||||
gitRepoEntity, err := git.OpenRepository(ctx, repo.RepoPath())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func(gitRepoEntity *git.Repository) {
|
||||
_ = gitRepoEntity.Close()
|
||||
}(gitRepoEntity)
|
||||
|
||||
// 3. 获取分支名称
|
||||
commit, err := gitRepoEntity.GetBranchCommit(branchName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
entry, err := commit.GetTreeEntryByPath(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// No way to edit a directory online.
|
||||
if entry.IsDir() {
|
||||
|
||||
return "", fmt.Errorf("%s entry.IsDir", path)
|
||||
}
|
||||
|
||||
blob := entry.Blob()
|
||||
if blob.Size() >= setting.UI.MaxDisplayFileSize {
|
||||
|
||||
return "", fmt.Errorf("%s blob.Size overflow", path)
|
||||
}
|
||||
|
||||
dataRc, err := blob.DataAsync()
|
||||
if err != nil {
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer dataRc.Close()
|
||||
buf := make([]byte, 1024)
|
||||
n, _ := util.ReadAtMost(dataRc, buf)
|
||||
buf = buf[:n]
|
||||
|
||||
// Only some file types are editable online as text.
|
||||
if !typesniffer.DetectContentType(buf).IsRepresentableAsText() {
|
||||
|
||||
return "", fmt.Errorf("typesniffer.IsRepresentableAsText")
|
||||
}
|
||||
|
||||
d, _ := io.ReadAll(dataRc)
|
||||
|
||||
buf = append(buf, d...)
|
||||
if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil {
|
||||
log.Error("ToUTF8: %v", err)
|
||||
return string(buf), nil
|
||||
} else {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// FileExists returns true if a file exists in the given repo branch
|
||||
func FileExists(path string, repo *gitea_context.Repository) (bool, error) {
|
||||
var branch = repo.BranchName
|
||||
if branch == "" {
|
||||
branch = repo.Repository.DefaultBranch
|
||||
}
|
||||
commit, err := repo.GitRepo.GetBranchCommit(branch)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := commit.GetTreeEntryByPath(path); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
func ParseImageName(imageName string) (registry, namespace, repo, tag string) {
|
||||
|
||||
// 分离仓库地址和命名空间
|
||||
parts := strings.Split(imageName, "/")
|
||||
if len(parts) == 3 {
|
||||
registry = parts[0]
|
||||
namespace = parts[1]
|
||||
repo = parts[2]
|
||||
} else if len(parts) == 2 {
|
||||
registry = parts[0]
|
||||
repo = parts[1]
|
||||
} else {
|
||||
repo = imageName
|
||||
}
|
||||
// 分离标签
|
||||
parts = strings.Split(repo, ":")
|
||||
if len(parts) > 1 {
|
||||
tag = parts[1]
|
||||
repo = parts[0]
|
||||
} else {
|
||||
tag = "latest"
|
||||
}
|
||||
if registry == "" {
|
||||
registry = "docker.io"
|
||||
}
|
||||
return registry, namespace, repo, tag
|
||||
}
|
||||
func getSanitizedDevcontainerName(username, repoName string) string {
|
||||
regexpNonAlphaNum := regexp.MustCompile(`[^a-zA-Z0-9]`)
|
||||
sanitizedUsername := regexpNonAlphaNum.ReplaceAllString(username, "")
|
||||
sanitizedRepoName := regexpNonAlphaNum.ReplaceAllString(repoName, "")
|
||||
if len(sanitizedUsername) > 15 {
|
||||
sanitizedUsername = strings.ToLower(sanitizedUsername[:15])
|
||||
}
|
||||
if len(sanitizedRepoName) > 31 {
|
||||
sanitizedRepoName = strings.ToLower(sanitizedRepoName[:31])
|
||||
}
|
||||
newUUID, _ := uuid.NewUUID()
|
||||
uuidStr := newUUID.String()
|
||||
uuidStr = regexpNonAlphaNum.ReplaceAllString(uuidStr, "")[:15]
|
||||
return fmt.Sprintf("%s-%s-%s", sanitizedUsername, sanitizedRepoName, uuidStr)
|
||||
}
|
||||
func ReplacePortOfUrl(originalURL, targetPort string) (string, error) {
|
||||
// 解析原始 URL
|
||||
parsedURL, _ := url.Parse(originalURL)
|
||||
|
||||
// 获取主机名和端口号
|
||||
host, _, err := net.SplitHostPort(parsedURL.Host)
|
||||
if err != nil {
|
||||
// 如果没有端口号,则 SplitHostPort 会返回错误
|
||||
// 这种情况下,Host 就是主机名
|
||||
host = parsedURL.Host
|
||||
}
|
||||
|
||||
// 重新组装 Host 和端口
|
||||
parsedURL.Host = net.JoinHostPort(host, targetPort)
|
||||
|
||||
// 生成新的 URL 字符串
|
||||
newURL := parsedURL.String()
|
||||
return newURL, nil
|
||||
}
|
||||
func GetPortFromURL(urlStr string) (string, error) {
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析URL失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取主机名和端口号
|
||||
_, port, err := net.SplitHostPort(parsedURL.Host)
|
||||
if err != nil {
|
||||
// 如果SplitHostPort失败,说明URL中没有明确指定端口
|
||||
// 需要根据协议判断默认端口
|
||||
switch parsedURL.Scheme {
|
||||
case "http":
|
||||
return "80", nil
|
||||
case "https":
|
||||
return "443", nil
|
||||
default:
|
||||
return "", fmt.Errorf("未知协议: %s", parsedURL.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果端口存在,直接返回
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// addFileToTar 将文件添加到 tar 归档
|
||||
func AddFileToTar(tw *tar.Writer, filename string, content string, mode int64) error {
|
||||
hdr := &tar.Header{
|
||||
Name: filename,
|
||||
Mode: mode,
|
||||
Size: int64(len(content)),
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write([]byte(content)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
596
services/devcontainer/docker_agent.go
Normal file
596
services/devcontainer/docker_agent.go
Normal file
@@ -0,0 +1,596 @@
|
||||
package devcontainer
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
devcontainer_models "code.gitea.io/gitea/models/devcontainer"
|
||||
"code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/user"
|
||||
docker_module "code.gitea.io/gitea/modules/docker"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/errdefs"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
func GetDevContainerStatusFromDocker(ctx context.Context, containerName string) (string, error) {
|
||||
// 创建docker client
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer cli.Close()
|
||||
containerID, err := docker_module.GetContainerID(cli, containerName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
containerStatus, err := docker_module.GetContainerStatus(cli, containerID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return containerStatus, nil
|
||||
}
|
||||
func CreateDevContainerByDockerAPI(ctx context.Context, newDevcontainer *devcontainer_models.Devcontainer, imageName string, repo *repo.Repository, publicKeyList []string) error {
|
||||
dbEngine := db.GetEngine(ctx)
|
||||
configurationString, err := GetDevcontainerConfigurationString(ctx, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configurationModel, err := UnmarshalDevcontainerConfigContent(configurationString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dockerSocket, err := docker_module.GetDockerSocketPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = dbEngine.Table("devcontainer").
|
||||
Where("user_id = ? AND repo_id = ? ", newDevcontainer.UserId, newDevcontainer.RepoId).
|
||||
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: 1})
|
||||
if err != nil {
|
||||
log.Info("err %v", err)
|
||||
}
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if configurationModel.Build == nil || configurationModel.Build.Dockerfile == "" {
|
||||
script := "docker " + "-H " + dockerSocket + " pull " + configurationModel.Image
|
||||
cmd := exec.Command("sh", "-c", script)
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = dbEngine.Table("devcontainer").
|
||||
Where("user_id = ? AND repo_id = ? ", newDevcontainer.UserId, newDevcontainer.RepoId).
|
||||
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: 2})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
docker_module.CreateAndStartContainer(ctx, cli, imageName,
|
||||
[]string{
|
||||
"sh",
|
||||
"-c",
|
||||
"tail -f /dev/null;",
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
nat.PortSet{
|
||||
nat.Port("22/tcp"): {},
|
||||
},
|
||||
newDevcontainer.Name)
|
||||
_, err = dbEngine.Table("devcontainer").
|
||||
Where("user_id = ? AND repo_id = ? ", newDevcontainer.UserId, newDevcontainer.RepoId).
|
||||
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: 3})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("ExecCommandInContainerExecCommandInContainerExecCommandInContainerExecCommandInContainerExecCommandInContainer")
|
||||
output, err := docker_module.ExecCommandInContainer(ctx, cli, newDevcontainer.Name,
|
||||
`echo "`+newDevcontainer.DevcontainerHost+` host.docker.internal" | tee -a /etc/hosts;apt update;apt install -y git ;git clone `+strings.TrimSuffix(setting.AppURL, "/")+repo.Link()+" "+newDevcontainer.DevcontainerWorkDir+"/"+repo.Name+`; apt install -y ssh;echo "PubkeyAuthentication yes `+"\n"+`PermitRootLogin yes `+"\n"+`" | tee -a /etc/ssh/sshd_config;rm -f /etc/ssh/ssh_host_*; ssh-keygen -A; service ssh restart;mkdir -p ~/.ssh;chmod 700 ~/.ssh;echo "`+strings.Join(publicKeyList, "\n")+`" > ~/.ssh/authorized_keys;chmod 600 ~/.ssh/authorized_keys;`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info(output)
|
||||
|
||||
_, err = dbEngine.Table("devcontainer").
|
||||
Where("user_id = ? AND repo_id = ? ", newDevcontainer.UserId, newDevcontainer.RepoId).
|
||||
Update(&devcontainer_models.Devcontainer{DevcontainerStatus: 4})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateDevContainerByDockerCommand(ctx context.Context, newDevcontainer *devcontainer_models.Devcontainer, repo *repo.Repository, publicKeyList []string) (string, error) {
|
||||
|
||||
dbEngine := db.GetEngine(ctx)
|
||||
configurationString, err := GetDevcontainerConfigurationString(ctx, repo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
configurationModel, err := UnmarshalDevcontainerConfigContent(configurationString)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var imageName = configurationModel.Image
|
||||
dockerSocket, err := docker_module.GetDockerSocketPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if configurationModel.Build != nil && configurationModel.Build.Dockerfile != "" {
|
||||
dockerfileContent, err := GetFileContentByPath(ctx, repo, ".devcontainer/"+configurationModel.Build.Dockerfile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 创建构建上下文(包含Dockerfile的tar包)
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
defer tw.Close()
|
||||
// 添加Dockerfile到tar包
|
||||
dockerfile := "Dockerfile"
|
||||
content := []byte(dockerfileContent)
|
||||
header := &tar.Header{
|
||||
Name: dockerfile,
|
||||
Size: int64(len(content)),
|
||||
Mode: 0644,
|
||||
}
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tw.Write(content); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 执行镜像构建
|
||||
imageName = fmt.Sprintf("%d", newDevcontainer.UserId) + "-" + fmt.Sprintf("%d", newDevcontainer.RepoId) + "-dockerfile"
|
||||
buildOptions := types.ImageBuildOptions{
|
||||
Tags: []string{imageName}, // 镜像标签
|
||||
}
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
buildResponse, err := cli.ImageBuild(
|
||||
context.Background(),
|
||||
&buf,
|
||||
buildOptions,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
output, err := io.ReadAll(buildResponse.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
log.Info(string(output))
|
||||
|
||||
}
|
||||
|
||||
// 拉取镜像的命令
|
||||
if _, err := dbEngine.Table("devcontainer_output").Insert(&devcontainer_models.DevcontainerOutput{
|
||||
Output: "",
|
||||
ListId: 1,
|
||||
Status: "waitting",
|
||||
UserId: newDevcontainer.UserId,
|
||||
RepoId: newDevcontainer.RepoId,
|
||||
Command: "docker " + "-H " + dockerSocket + " pull " + imageName + "\n",
|
||||
DevcontainerId: newDevcontainer.Id,
|
||||
}); err != nil {
|
||||
log.Info("Failed to insert record: %v", err)
|
||||
return imageName, err
|
||||
}
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
if err != nil {
|
||||
return imageName, err
|
||||
}
|
||||
var startCommand string = `docker -H ` + dockerSocket + ` run --restart=always -d --name ` + newDevcontainer.Name
|
||||
|
||||
// 将每个端口转换为 "-p <port>" 格式
|
||||
var portFlags string = " -p 22 "
|
||||
|
||||
exposedPorts := configurationModel.ExtractContainerPorts()
|
||||
for _, port := range exposedPorts {
|
||||
portFlags = portFlags + fmt.Sprintf(" -p %d ", port)
|
||||
}
|
||||
startCommand += portFlags
|
||||
var envFlags string = ` -e RepoLink="` + strings.TrimSuffix(cfg.Section("server").Key("ROOT_URL").Value(), `/`) + repo.Link() + `" ` +
|
||||
` -e DevstarHost="` + newDevcontainer.DevcontainerHost + `"` +
|
||||
` -e WorkSpace="` + newDevcontainer.DevcontainerWorkDir + `/` + repo.Name + `"` +
|
||||
` -e PublicKeyList="` + strings.Join(publicKeyList, "\n") + `" `
|
||||
// 遍历 ContainerEnv 映射中的每个环境变量
|
||||
for name, value := range configurationModel.ContainerEnv {
|
||||
// 将每个环境变量转换为 "-e name=value" 格式
|
||||
envFlags = envFlags + fmt.Sprintf(" -e %s=\"%s\" ", name, value)
|
||||
}
|
||||
startCommand += envFlags
|
||||
if configurationModel.Init {
|
||||
startCommand += " --init "
|
||||
}
|
||||
if configurationModel.Privileged {
|
||||
startCommand += " --privileged "
|
||||
}
|
||||
|
||||
var capAddFlags string
|
||||
// 遍历 CapAdd 列表中的每个能力
|
||||
for _, capability := range configurationModel.CapAdd {
|
||||
// 将每个能力转换为 --cap-add=capability 格式
|
||||
capAddFlags = capAddFlags + fmt.Sprintf(" --cap-add %s ", capability)
|
||||
}
|
||||
startCommand += capAddFlags
|
||||
|
||||
var securityOptFlags string
|
||||
// 遍历 SecurityOpt 列表中的每个安全选项
|
||||
for _, option := range configurationModel.SecurityOpt {
|
||||
// 将每个选项转换为 --security-opt=option 格式
|
||||
securityOptFlags = securityOptFlags + fmt.Sprintf(" --security-opt %s ", option)
|
||||
}
|
||||
startCommand += securityOptFlags
|
||||
startCommand += " " + strings.Join(configurationModel.ExtractMountFlags(), " ") + " "
|
||||
if configurationModel.WorkspaceFolder != "" {
|
||||
startCommand += fmt.Sprintf(" -w %s ", configurationModel.WorkspaceFolder)
|
||||
}
|
||||
startCommand += " " + strings.Join(configurationModel.RunArgs, " ") + " "
|
||||
//创建并运行容器的命令
|
||||
if _, err := dbEngine.Table("devcontainer_output").Insert(&devcontainer_models.DevcontainerOutput{
|
||||
Output: "",
|
||||
Status: "waitting",
|
||||
UserId: newDevcontainer.UserId,
|
||||
RepoId: newDevcontainer.RepoId,
|
||||
Command: startCommand + imageName + ` sh -c "tail -f /dev/null"` + "\n",
|
||||
ListId: 2,
|
||||
DevcontainerId: newDevcontainer.Id,
|
||||
}); err != nil {
|
||||
log.Info("Failed to insert record: %v", err)
|
||||
return imageName, err
|
||||
}
|
||||
//安装基本工具的命令
|
||||
onCreateCommand := strings.TrimSpace(strings.Join(configurationModel.ParseCommand(configurationModel.OnCreateCommand), ";"))
|
||||
if !strings.HasSuffix(onCreateCommand, ";") {
|
||||
onCreateCommand += ";"
|
||||
}
|
||||
if onCreateCommand == ";" {
|
||||
onCreateCommand = ""
|
||||
}
|
||||
if _, err := dbEngine.Table("devcontainer_output").Insert(&devcontainer_models.DevcontainerOutput{
|
||||
Output: "",
|
||||
Status: "waitting",
|
||||
UserId: newDevcontainer.UserId,
|
||||
RepoId: newDevcontainer.RepoId,
|
||||
Command: `docker -H ` + dockerSocket + ` exec ` + newDevcontainer.Name + ` /home/webTerminal.sh start; ` +
|
||||
onCreateCommand + "\n",
|
||||
ListId: 3,
|
||||
DevcontainerId: newDevcontainer.Id,
|
||||
}); err != nil {
|
||||
log.Info("Failed to insert record: %v", err)
|
||||
return imageName, err
|
||||
}
|
||||
//连接容器的命令
|
||||
if _, err := dbEngine.Table("devcontainer_output").Insert(&devcontainer_models.DevcontainerOutput{
|
||||
Output: "",
|
||||
Status: "waitting",
|
||||
UserId: newDevcontainer.UserId,
|
||||
RepoId: newDevcontainer.RepoId,
|
||||
Command: `docker -H ` + dockerSocket + ` exec -it --workdir ` + newDevcontainer.DevcontainerWorkDir + "/" + repo.Name + ` ` + newDevcontainer.Name + ` sh -c "echo 'Successfully connected to the container';bash"` + "\n",
|
||||
ListId: 4,
|
||||
DevcontainerId: newDevcontainer.Id,
|
||||
}); err != nil {
|
||||
log.Info("Failed to insert record: %v", err)
|
||||
return imageName, err
|
||||
}
|
||||
return imageName, nil
|
||||
}
|
||||
func IsContainerNotFound(ctx context.Context, containerName string) (bool, error) {
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer cli.Close()
|
||||
containerID, err := docker_module.GetContainerID(cli, containerName)
|
||||
if err != nil {
|
||||
// 检查是否为 "未找到" 错误
|
||||
if errdefs.IsNotFound(err) {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
_, err = cli.ContainerInspect(ctx, containerID)
|
||||
if err != nil {
|
||||
|
||||
// 检查是否为 "未找到" 错误
|
||||
if docker_module.IsContainerNotFound(err) {
|
||||
return true, nil
|
||||
}
|
||||
// 其他类型的错误
|
||||
return false, err
|
||||
}
|
||||
// 无错误表示容器存在
|
||||
return false, nil
|
||||
}
|
||||
func DeleteDevContainerByDocker(ctx context.Context, devContainerName string) error {
|
||||
// 创建docker client
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
// 获取容器 ID
|
||||
|
||||
containerID, err := docker_module.GetContainerID(cli, devContainerName)
|
||||
if err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 删除容器
|
||||
if err := docker_module.DeleteContainer(ctx, cli, containerID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func RestartDevContainerByDocker(ctx context.Context, devContainerName string) error {
|
||||
// 创建docker client
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
// 获取容器 ID
|
||||
|
||||
containerID, err := docker_module.GetContainerID(cli, devContainerName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// restart容器
|
||||
timeout := 10 // 超时时间(秒)
|
||||
err = cli.ContainerRestart(context.Background(), containerID, container.StopOptions{
|
||||
Timeout: &timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
} else {
|
||||
log.Info("容器已重启")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func StopDevContainerByDocker(ctx context.Context, devContainerName string) error {
|
||||
// 创建docker client
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
// 获取容器 ID
|
||||
containerID, err := docker_module.GetContainerID(cli, devContainerName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// stop容器
|
||||
timeout := 10 // 超时时间(秒)
|
||||
err = cli.ContainerStop(context.Background(), containerID, container.StopOptions{
|
||||
Timeout: &timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
} else {
|
||||
log.Info("容器已停止")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func UpdateDevContainerByDocker(ctx context.Context, devContainerInfo *devcontainer_models.Devcontainer, updateInfo *UpdateInfo, repo *repo.Repository, doer *user.User) error {
|
||||
// 创建docker client
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
// update容器
|
||||
imageRef := updateInfo.RepositoryAddress + "/" + updateInfo.RepositoryUsername + "/" + updateInfo.ImageName
|
||||
configurationString, err := GetDevcontainerConfigurationString(ctx, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configurationModel, err := UnmarshalDevcontainerConfigContent(configurationString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if updateInfo.SaveMethod == "on" {
|
||||
// 创建构建上下文(包含Dockerfile的tar包)
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
defer tw.Close()
|
||||
// 添加Dockerfile到tar包
|
||||
dockerfile := "Dockerfile"
|
||||
dockerfileContent, err := GetFileContentByPath(ctx, repo, ".devcontainer/"+configurationModel.Build.Dockerfile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content := []byte(dockerfileContent)
|
||||
header := &tar.Header{
|
||||
Name: dockerfile,
|
||||
Size: int64(len(content)),
|
||||
Mode: 0644,
|
||||
}
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(content); err != nil {
|
||||
return err
|
||||
}
|
||||
buildOptions := types.ImageBuildOptions{
|
||||
Tags: []string{imageRef}, // 镜像标签
|
||||
}
|
||||
|
||||
_, err = cli.ImageBuild(
|
||||
context.Background(),
|
||||
&buf,
|
||||
buildOptions,
|
||||
)
|
||||
if err != nil {
|
||||
log.Info(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
} else {
|
||||
// 获取容器 ID
|
||||
containerID, err := docker_module.GetContainerID(cli, devContainerInfo.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 提交容器
|
||||
_, err = cli.ContainerCommit(ctx, containerID, types.ContainerCommitOptions{Reference: imageRef})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 推送到仓库
|
||||
dockerHost, err := docker_module.GetDockerSocketPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = docker_module.PushImage(dockerHost, updateInfo.RepositoryUsername, updateInfo.PassWord, updateInfo.RepositoryAddress, imageRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 定义正则表达式来匹配 image 字段
|
||||
re := regexp.MustCompile(`"image"\s*:\s*"([^"]+)"`)
|
||||
// 使用正则表达式查找并替换 image 字段的值
|
||||
newConfiguration := re.ReplaceAllString(configurationString, `"image": "`+imageRef+`"`)
|
||||
err = UpdateDevcontainerConfiguration(newConfiguration, repo, doer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImageExists 检查指定镜像是否存在
|
||||
// 返回值:
|
||||
// - bool: 镜像是否存在(true=存在,false=不存在)
|
||||
// - error: 非空表示检查过程中发生错误
|
||||
func ImageExists(ctx context.Context, imageName string) (bool, error) {
|
||||
|
||||
// 创建 Docker 客户端
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
if err != nil {
|
||||
return false, err // 其他错误
|
||||
}
|
||||
// 获取镜像信息
|
||||
_, _, err = cli.ImageInspectWithRaw(ctx, imageName)
|
||||
if err != nil {
|
||||
if client.IsErrNotFound(err) {
|
||||
return false, nil // 镜像不存在,但不是错误
|
||||
}
|
||||
return false, err // 其他错误
|
||||
}
|
||||
return true, nil // 镜像存在
|
||||
}
|
||||
func CheckDirExistsFromDocker(ctx context.Context, containerName, dirPath string) (bool, error) {
|
||||
// 上下文
|
||||
// 创建 Docker 客户端
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 获取容器 ID
|
||||
containerID, err := docker_module.GetContainerID(cli, containerName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 创建 exec 配置
|
||||
execConfig := types.ExecConfig{
|
||||
Cmd: []string{"test", "-d", dirPath}, // 检查目录是否存在
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
}
|
||||
|
||||
// 创建 exec 实例
|
||||
execResp, err := cli.ContainerExecCreate(context.Background(), containerID, execConfig)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// 执行命令
|
||||
var exitCode int
|
||||
err = cli.ContainerExecStart(context.Background(), execResp.ID, types.ExecStartCheck{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// 获取命令执行结果
|
||||
resp, err := cli.ContainerExecInspect(context.Background(), execResp.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
exitCode = resp.ExitCode
|
||||
return exitCode == 0, nil // 退出码为 0 表示目录存在
|
||||
}
|
||||
func RegistWebTerminal(ctx context.Context) error {
|
||||
log.Info("开始构建WebTerminal...")
|
||||
cli, err := docker_module.CreateDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
//拉取web_terminal镜像
|
||||
dockerHost, err := docker_module.GetDockerSocketPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取docker socket路径失败:%v", err)
|
||||
}
|
||||
// 拉取镜像
|
||||
err = docker_module.PullImage(ctx, cli, dockerHost, setting.DevContainerConfig.Web_Terminal_Image)
|
||||
if err != nil {
|
||||
return fmt.Errorf("拉取web_terminal镜像失败:%v", err)
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("20060102150405")
|
||||
binds := []string{
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
}
|
||||
containerName := "webterminal-" + timestamp
|
||||
//创建并启动WebTerminal容器
|
||||
err = docker_module.CreateAndStartContainer(ctx, cli, setting.DevContainerConfig.Web_Terminal_Image,
|
||||
nil,
|
||||
nil, binds,
|
||||
nat.PortSet{
|
||||
"7681/tcp": struct{}{},
|
||||
},
|
||||
containerName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建并注册WebTerminal失败:%v", err)
|
||||
}
|
||||
// Save settings.
|
||||
cfg, err := setting.NewConfigProviderFromFile(setting.CustomConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = docker_module.GetMappedPort(ctx, containerName, "7681")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Section("devcontainer").Key("WEB_TERMINAL_CONTAINER").SetValue(containerName)
|
||||
if err = cfg.SaveTo(setting.CustomConf); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -49,6 +49,7 @@ type AdminEditUserForm struct {
|
||||
AllowGitHook bool
|
||||
AllowImportLocal bool
|
||||
AllowCreateOrganization bool
|
||||
AllowCreateDevcontainer bool
|
||||
ProhibitLogin bool
|
||||
Reset2FA bool `form:"reset_2fa"`
|
||||
Visibility structs.VisibleType
|
||||
|
||||
21
services/forms/devcontainer_form.go
Normal file
21
services/forms/devcontainer_form.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/web/middleware"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"gitea.com/go-chi/binding"
|
||||
)
|
||||
|
||||
// CreateRepoDevcontainerForm 用户使用 API 创建仓库 DevContainer POST 表单数据绑定与校验规则
|
||||
type CreateRepoDevcontainerForm struct {
|
||||
RepoId string `json:"repoId" binding:"Required;MinSize(1);MaxSize(19);PositiveBase10IntegerNumberRule"`
|
||||
SSHPublicKeyList []string `json:"sshPublicKeyList"`
|
||||
}
|
||||
|
||||
// Validate 用户使用 API 创建仓库 DevContainer POST 表单数据校验器
|
||||
func (f *CreateRepoDevcontainerForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
|
||||
ctx := context.GetValidateContext(req)
|
||||
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func registDockerRunner(ctx context.Context, token string) error {
|
||||
return fmt.Errorf("获取docker socket路径失败:%v", err)
|
||||
}
|
||||
// 拉取镜像
|
||||
err = docker_module.PullImage(cli, dockerHost, setting.Runner.Image)
|
||||
err = docker_module.PullImage(ctx, cli, dockerHost, setting.Runner.Image)
|
||||
if err != nil {
|
||||
return fmt.Errorf("拉取act_runner镜像失败:%v", err)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func registDockerRunner(ctx context.Context, token string) error {
|
||||
}
|
||||
containerName := "runner-" + timestamp
|
||||
//创建并启动Runner容器
|
||||
err = docker_module.CreateAndStartContainer(cli, setting.Runner.Image, nil, env, binds, nil, containerName)
|
||||
err = docker_module.CreateAndStartContainer(ctx, cli, setting.Runner.Image, nil, env, binds, nil, containerName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建并注册Runner失败:%v", err)
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func deleteDockerRunnerByName(ctx context.Context, runnerName string) error {
|
||||
}
|
||||
log.Info("[StopAndRemoveContainer]Docker client创建成功")
|
||||
defer cli.Close()
|
||||
err = docker_module.DeleteContainer(cli, runnerName)
|
||||
err = docker_module.DeleteContainer(ctx, cli, runnerName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Runner创建失败:%v", err)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ type UpdateOptions struct {
|
||||
Theme optional.Option[string]
|
||||
DiffViewStyle optional.Option[string]
|
||||
AllowCreateOrganization optional.Option[bool]
|
||||
AllowCreateDevcontainer optional.Option[bool]
|
||||
IsActive optional.Option[bool]
|
||||
IsAdmin optional.Option[UpdateOptionField[bool]]
|
||||
EmailNotificationsPreference optional.Option[string]
|
||||
@@ -164,6 +165,11 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
|
||||
|
||||
cols = append(cols, "allow_create_organization")
|
||||
}
|
||||
if opts.AllowCreateDevcontainer.Has() {
|
||||
u.AllowCreateDevcontainer = opts.AllowCreateDevcontainer.Value()
|
||||
|
||||
cols = append(cols, "allow_create_devcontainer")
|
||||
}
|
||||
if opts.RepoAdminChangeTeamAccess.Has() {
|
||||
u.RepoAdminChangeTeamAccess = opts.RepoAdminChangeTeamAccess.Value()
|
||||
|
||||
|
||||
@@ -148,6 +148,12 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox">
|
||||
<label><strong>{{ctx.Locale.Tr "admin.users.allow_create_devcontainer"}}</strong></label>
|
||||
<input name="allow_create_devcontainer" type="checkbox" {{if .User.AllowCreateDevcontainer}}checked{{end}}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .TwoFactorEnabled}}
|
||||
<div class="divider"></div>
|
||||
|
||||
@@ -158,9 +158,10 @@
|
||||
<!-- Optional Settings -->
|
||||
<h4 class="ui dividing header">{{ctx.Locale.Tr "install.optional_title"}}</h4>
|
||||
<div>
|
||||
|
||||
<!-- k8s -->
|
||||
<details class="optional field">
|
||||
<summary class="right-content tw-py-2{{if .Err_SMTP}} text red{{end}}">
|
||||
<summary class="right-content tw-py-2{{if .Err_K8s}} text red{{end}}">
|
||||
{{ctx.Locale.Tr "install.k8s_title"}}
|
||||
</summary>
|
||||
<div class="inline field">
|
||||
@@ -224,6 +225,7 @@
|
||||
<summary class="right-content tw-py-2{{if .Err_Services}} text red{{end}}">
|
||||
{{ctx.Locale.Tr "install.server_service_title"}}
|
||||
</summary>
|
||||
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox" id="offline-mode">
|
||||
<label data-tooltip-content="{{ctx.Locale.Tr "install.offline_mode_popup"}}">{{ctx.Locale.Tr "install.offline_mode"}}</label>
|
||||
|
||||
414
templates/repo/devcontainer/details.tmpl
Normal file
414
templates/repo/devcontainer/details.tmpl
Normal file
@@ -0,0 +1,414 @@
|
||||
{{template "base/head" .}}
|
||||
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki pages">
|
||||
{{template "repo/header" .}}
|
||||
<div class="ui container">
|
||||
{{template "base/alert" .}}
|
||||
<!-- 开始:Dev Container 正文 -->
|
||||
<div class="issue-content">
|
||||
<!-- 开始:Dev Container 正文内容 - 左侧主展示区 -->
|
||||
<div class="issue-content-left">
|
||||
{{if not .HasDevContainerConfiguration}}
|
||||
|
||||
<div class="empty-placeholder">
|
||||
{{svg "octicon-container" 48}}
|
||||
<h2>{{ctx.Locale.Tr "repo.dev_container_empty"}}</h2>
|
||||
{{if .isAdmin}}
|
||||
<form method="get" action="{{.CreateDevcontainerSettingUrl}}" class="ui edit form">
|
||||
<button class="ui primary button" type="submit">Create</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{else}}
|
||||
|
||||
<div class="ui container">
|
||||
<form class="ui edit form">
|
||||
<div class="repo-editor-header">
|
||||
<div class="ui breadcrumb field">
|
||||
<a class="section" href="{{$.BranchLink}}">{{.Repository.Name}}</a>
|
||||
{{range $i, $v := .TreeNames}}
|
||||
<div class="breadcrumb-divider">/</div>
|
||||
<span class="section"><a href="{{$.BranchLink}}/{{index $.TreePaths $i | PathEscapeSegments}}">{{$v}}</a></span>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
<a href="{{.EditDevcontainerConfigurationUrl}}"><div class="ui primary button" style="margin-left: 10px;width: 4em;height: 1em;">Edit</div></a>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
<iframe id="webTerminalContainer" src="{{.WebSSHUrl}}" width="100%" style="height: 100vh; display: none;" frameborder="0">您的浏览器不支持iframe</iframe>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<!-- 结束:Dev Container 正文内容 - 左侧主展示区 -->
|
||||
|
||||
<!-- 开始:Dev Container 正文内容 - 右侧展示区 -->
|
||||
<div class="issue-content-right ui segment">
|
||||
<strong>Options</strong>
|
||||
<div class="ui relaxed list">
|
||||
|
||||
{{if .HasDevContainer}}
|
||||
<div style=" display: none;" id="deleteContainer" class="item"><a class="delete-button flex-text-inline" data-modal="#delete-repo-devcontainer-of-user-modal" href="#" data-url="{{.Repository.Link}}/devcontainer/delete">{{svg "octicon-trash" 14}}{{ctx.Locale.Tr "repo.dev_container_control.delete"}}</a></div>
|
||||
{{if .isAdmin}}
|
||||
<div style=" display: none;" id="updateContainer" class="item"><a class="delete-button flex-text-inline" style="color:black; " data-modal-id="updatemodal" href="#">{{svg "octicon-database"}}{{ctx.Locale.Tr "repo.dev_container_control.update"}}</a></div>
|
||||
{{end}}
|
||||
|
||||
<div style=" display: none;" id="restartContainer" class="item"><button class="flex-text-inline" style="color:black; " >{{svg "octicon-terminal" 14 "tw-mr-2"}} Restart Dev Container</button></div>
|
||||
<div style=" display: none;" id="stopContainer" class="item"><button class="flex-text-inline" style="color:black; " >{{svg "octicon-terminal" 14 "tw-mr-2"}} Stop Dev Container</button></div>
|
||||
|
||||
<div style=" display: none;" id="webTerminal" class="item"><a class="flex-text-inline" style="color:black; " href="{{.WebSSHUrl}}" target="_blank">{{svg "octicon-code" 14}}open with WebTerminal</a></div>
|
||||
<div style=" display: none;" id="vsTerminal" class="item"><a class="flex-text-inline" style="color:black; " onclick="window.location.href = '{{.VSCodeUrl}}'">{{svg "octicon-code" 14}}open with VSCode</a ></div>
|
||||
<div style=" display: none;" id="cursorTerminal" class="item"><a class="flex-text-inline" style="color:black; " onclick="window.location.href = '{{.CursorUrl}}'">{{svg "octicon-code" 14}}open with Cursor</a ></div>
|
||||
<div style=" display: none;" id="windsurfTerminal" class="item"><a class="flex-text-inline" style="color:black;" onclick="window.location.href = '{{.WindsurfUrl}}'">{{svg "octicon-code" 14}}open with Windsurf</a ></div>
|
||||
|
||||
{{end}}
|
||||
{{if .ValidateDevContainerConfiguration}}
|
||||
<div style=" display: none;" id="createContainer" class="item">
|
||||
<div>
|
||||
<form method="get" action="{{.Repository.Link}}/devcontainer/create" class="ui edit form">
|
||||
<button class="flex-text-inline" type="submit">{{svg "octicon-terminal" 14 "tw-mr-2"}} Create Dev Container</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="loading" class="loading"></div>
|
||||
{{end}}
|
||||
{{if not .ValidateDevContainerConfiguration}}
|
||||
<div class="item">{{svg "octicon-alert" 16 "tw-mr-2"}} {{ctx.Locale.Tr "repo.dev_container_invalid_config_prompt"}} </div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- 结束:Dev Container 正文内容 - 右侧展示区 -->
|
||||
</div>
|
||||
|
||||
<!-- 结束Dev Container 正文内容 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认删除 Dev Container 模态对话框 -->
|
||||
<div class="ui g-modal-confirm delete modal" id="delete-repo-devcontainer-of-user-modal">
|
||||
<div class="header">
|
||||
{{svg "octicon-trash"}}
|
||||
{{ctx.Locale.Tr "repo.dev_container_control.delete"}}
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>{{ctx.Locale.Tr "repo.dev_container_control.deletion_desc"}}</p>
|
||||
</div>
|
||||
{{template "base/modal_actions_confirm" .}}
|
||||
</div>
|
||||
<!-- 确认 Dev Container 模态对话框 -->
|
||||
<div class="ui g-modal-confirm delete modal" style="width: 35%" id="updatemodal">
|
||||
<div class="header">
|
||||
{{ctx.Locale.Tr "repo.dev_container_control.update"}}
|
||||
</div>
|
||||
<div class="content">
|
||||
<form class="ui form tw-max-w-2xl tw-m-auto" id="updateForm" onsubmit="submitForm(event)">
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox">
|
||||
{{if not .HasDevContainerDockerfile}}
|
||||
<input type="checkbox" id="SaveMethod" name="SaveMethod" disabled>
|
||||
{{else}}
|
||||
<input type="checkbox" id="SaveMethod" name="SaveMethod" value="on">
|
||||
{{end}}
|
||||
<label for="SaveMethod">Build From Dockerfile</label>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="required field ">
|
||||
<label for="RepositoryAddress">Registry:</label>
|
||||
<input style="border: 1px solid black;" type="text" id="RepositoryAddress" name="RepositoryAddress" value="{{.RepositoryAddress}}">
|
||||
</div>
|
||||
<div class="required field ">
|
||||
<label for="RepositoryUsername">Registry Username:</label>
|
||||
<input style="border: 1px solid black;" type="text" id="RepositoryUsername" name="RepositoryUsername" value="{{.RepositoryUsername}}">
|
||||
</div>
|
||||
<div class="required field ">
|
||||
<label for="RepositoryPassword">Registry Password:</label>
|
||||
<input style="border: 1px solid black;" type="text" id="RepositoryPassword" name="RepositoryPassword" required>
|
||||
</div>
|
||||
<div class="required field ">
|
||||
<label for="ImageName">Image(name:tag):</label>
|
||||
<input style="border: 1px solid black;" type="text" id="ImageName" name="ImageName" value="{{.ImageName}}">
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="ui primary button" type="submit" id="updateSubmitButton" >Submit</button>
|
||||
<button class="ui cancel button" id="updateCloseButton">Close</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
var status = '-1'
|
||||
var intervalID
|
||||
const createContainer = document.getElementById('createContainer');
|
||||
const deleteContainer = document.getElementById('deleteContainer');
|
||||
const updateContainer = document.getElementById('updateContainer');
|
||||
const restartContainer = document.getElementById('restartContainer');
|
||||
const stopContainer = document.getElementById('stopContainer');
|
||||
const webTerminal = document.getElementById('webTerminal');
|
||||
const vsTerminal = document.getElementById('vsTerminal');
|
||||
const cursorTerminal = document.getElementById('cursorTerminal');
|
||||
const windsurfTerminal = document.getElementById('windsurfTerminal');
|
||||
const webTerminalContainer = document.getElementById('webTerminalContainer');
|
||||
const loadingElement = document.getElementById('loading');
|
||||
|
||||
function concealElement(){
|
||||
if (createContainer){
|
||||
createContainer.style.display = 'none';
|
||||
}
|
||||
if (deleteContainer){
|
||||
deleteContainer.style.display = 'none';
|
||||
}
|
||||
if (updateContainer) {
|
||||
updateContainer.style.display = 'none';
|
||||
}
|
||||
if (restartContainer) {
|
||||
restartContainer.style.display = 'none';
|
||||
}
|
||||
if (stopContainer) {
|
||||
stopContainer.style.display = 'none';
|
||||
}
|
||||
if (webTerminal) {
|
||||
webTerminal.style.display = 'none';
|
||||
}
|
||||
if (vsTerminal) {
|
||||
vsTerminal.style.display = 'none';
|
||||
}
|
||||
if (cursorTerminal) {
|
||||
cursorTerminal.style.display = 'none';
|
||||
}
|
||||
if (windsurfTerminal) {
|
||||
windsurfTerminal.style.display = 'none';
|
||||
}
|
||||
if (webTerminalContainer) {
|
||||
webTerminalContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
function displayElement(){
|
||||
if (deleteContainer){
|
||||
deleteContainer.style.display = 'block';
|
||||
}
|
||||
if (updateContainer) {
|
||||
updateContainer.style.display = 'block';
|
||||
}
|
||||
if (restartContainer) {
|
||||
restartContainer.style.display = 'block';
|
||||
}
|
||||
if (stopContainer) {
|
||||
stopContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
if (webTerminal) {
|
||||
webTerminal.style.display = 'block';
|
||||
}
|
||||
|
||||
if (vsTerminal) {
|
||||
vsTerminal.style.display = 'block';
|
||||
}
|
||||
|
||||
if (cursorTerminal) {
|
||||
cursorTerminal.style.display = 'block';
|
||||
}
|
||||
|
||||
if (windsurfTerminal) {
|
||||
windsurfTerminal.style.display = 'block';
|
||||
}
|
||||
if (webTerminalContainer) {
|
||||
webTerminalContainer.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function getStatus() {
|
||||
fetch(
|
||||
'{{.Repository.Link}}'+'/devcontainer/status'
|
||||
)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status == '-1' || data.status == '') {
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'none';
|
||||
}
|
||||
if (createContainer){
|
||||
createContainer.style.display = 'block';
|
||||
}
|
||||
clearInterval(intervalID);
|
||||
} else if (data.status == '0' || data.status == '1' || data.status == '2') {
|
||||
concealElement();
|
||||
if (webTerminalContainer) {
|
||||
webTerminalContainer.style.display = 'block';
|
||||
}
|
||||
if (deleteContainer){
|
||||
deleteContainer.style.display = 'block';
|
||||
}
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'block';
|
||||
}
|
||||
}else if (data.status == '3') {
|
||||
concealElement();
|
||||
if (deleteContainer){
|
||||
deleteContainer.style.display = 'block';
|
||||
}
|
||||
if (webTerminalContainer) {
|
||||
webTerminalContainer.style.display = 'block';
|
||||
}
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'block';
|
||||
}
|
||||
}else if (data.status == '4') {
|
||||
displayElement();
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'none';
|
||||
}
|
||||
clearInterval(intervalID);
|
||||
}else if (data.status == '5') {
|
||||
concealElement();
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'block';
|
||||
}
|
||||
}else if (data.status == '6') {
|
||||
concealElement();
|
||||
if (deleteContainer){
|
||||
deleteContainer.style.display = 'block';
|
||||
}
|
||||
if (updateContainer) {
|
||||
updateContainer.style.display = 'block';
|
||||
}
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'block';
|
||||
}
|
||||
}else if (data.status == '7') {
|
||||
concealElement();
|
||||
if (deleteContainer){
|
||||
deleteContainer.style.display = 'block';
|
||||
}
|
||||
if (updateContainer) {
|
||||
updateContainer.style.display = 'block';
|
||||
}
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'block';
|
||||
}
|
||||
}else if (data.status == '8') {
|
||||
concealElement();
|
||||
if (deleteContainer){
|
||||
deleteContainer.style.display = 'block';
|
||||
}
|
||||
if (updateContainer) {
|
||||
updateContainer.style.display = 'block';
|
||||
}
|
||||
if (restartContainer) {
|
||||
restartContainer.style.display = 'block';
|
||||
}
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'none';
|
||||
}
|
||||
clearInterval(intervalID);
|
||||
}else if (data.status == '9') {
|
||||
concealElement();
|
||||
if (loadingElement) {
|
||||
loadingElement.style.display = 'block';
|
||||
}
|
||||
}
|
||||
if(status !== '9' && status !== '-1' && data.status == '9'){
|
||||
window.location.reload();
|
||||
}
|
||||
if(status !== '-1' && data.status == '-1'){
|
||||
window.location.reload();
|
||||
}
|
||||
status = data.status
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
intervalID = setInterval(getStatus, 3000);
|
||||
if (restartContainer) {
|
||||
restartContainer.addEventListener('click', function(event) {
|
||||
// 处理点击逻辑
|
||||
concealElement();
|
||||
fetch('{{.Repository.Link}}' + '/devcontainer/restart')
|
||||
.then(response => {intervalID = setInterval(getStatus, 3000);})
|
||||
});
|
||||
}
|
||||
if (stopContainer) {
|
||||
stopContainer.addEventListener('click', function(event) {
|
||||
concealElement();
|
||||
// 处理点击逻辑
|
||||
fetch('{{.Repository.Link}}' + '/devcontainer/stop')
|
||||
.then(response => {intervalID = setInterval(getStatus, 3000);})
|
||||
|
||||
});
|
||||
}
|
||||
if (deleteContainer) {
|
||||
deleteContainer.addEventListener('click', function(event) {
|
||||
setInterval(getStatus, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
function submitForm(event) {
|
||||
event.preventDefault(); // 阻止默认的表单提交行为
|
||||
const {csrfToken} = window.config;
|
||||
const {appSubUrl} = window.config;
|
||||
const form = document.getElementById('updateForm');
|
||||
const submitButton = document.getElementById('updateSubmitButton');
|
||||
const closeButton = document.getElementById('updateCloseButton');
|
||||
submitButton.disabled = true;
|
||||
const formData = new FormData(form);
|
||||
fetch('{{.Repository.Link}}'+'/devcontainer/update', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-csrf-token': csrfToken, // 如果需要认证
|
||||
'content-type' : 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
RepositoryAddress: formData.get('RepositoryAddress'),
|
||||
RepositoryUsername: formData.get('RepositoryUsername'),
|
||||
RepositoryPassword: formData.get('RepositoryPassword'),
|
||||
SaveMethod: formData.get('SaveMethod'),
|
||||
ImageName: formData.get('ImageName'),
|
||||
})
|
||||
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
submitButton.disabled = false;
|
||||
alert(data.message);
|
||||
if(data.redirect){
|
||||
closeButton.click()
|
||||
}
|
||||
intervalID = setInterval(getStatus, 3000);
|
||||
})
|
||||
.catch((error) => {
|
||||
submitButton.disabled = false;
|
||||
alert('提交失败,请重试。');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.loading{
|
||||
width:60px;
|
||||
height:60px;
|
||||
border-radius:150px;
|
||||
border:8px solid #fff;
|
||||
border-top-color:rgba(0,0,0,0.3);
|
||||
box-sizing:border-box;
|
||||
margin-left:calc(50% - 30px);
|
||||
animation:loading 1.2s linear infinite;
|
||||
-webkit-animation:loading 1.2s linear infinite;
|
||||
}
|
||||
@keyframes loading{
|
||||
0%{transform:rotate(0deg)}
|
||||
100%{transform:rotate(360deg)}
|
||||
}
|
||||
@-webkit-keyframes loading{
|
||||
0%{-webkit-transform:rotate(0deg)}
|
||||
100%{-webkit-transform:rotate(360deg)}
|
||||
}
|
||||
|
||||
</style>
|
||||
{{template "base/footer" .}}
|
||||
1097
templates/repo/devcontainer/vscode-home-js.tmpl
Normal file
1097
templates/repo/devcontainer/vscode-home-js.tmpl
Normal file
repo.diff.file_suppressed
repo.diff.load
434
templates/repo/devcontainer/vscode-home.tmpl
Normal file
434
templates/repo/devcontainer/vscode-home.tmpl
Normal file
@@ -0,0 +1,434 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ctx.Locale.Lang}}" data-theme="{{UserThemeName .SignedUser}}">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .Title}}{{.Title}} - {{end}}{{if .Repository.Name}}{{.Repository.Name}} - {{end}}{{AppName}}</title>
|
||||
{{if .ManifestData}}<link rel="manifest" href="data:{{.ManifestData}}">{{end}}
|
||||
<meta name="author" content="{{if .Repository}}{{.Owner.Name}}{{else}}{{MetaAuthor}}{{end}}">
|
||||
<meta name="description" content="{{if .Repository}}{{.Repository.Name}}{{if .Repository.Description}} - {{.Repository.Description}}{{end}}{{else}}{{MetaDescription}}{{end}}">
|
||||
<meta name="keywords" content="{{MetaKeywords}}">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
{{if .GoGetImport}}
|
||||
<meta name="go-import" content="{{.GoGetImport}} git {{.RepoCloneLink.HTTPS}}">
|
||||
<meta name="go-source" content="{{.GoGetImport}} _ {{.GoDocDirectory}} {{.GoDocFile}}">
|
||||
{{end}}
|
||||
{{if and .EnableFeed .FeedURL}}
|
||||
<link rel="alternate" type="application/atom+xml" title="" href="{{.FeedURL}}.atom">
|
||||
<link rel="alternate" type="application/rss+xml" title="" href="{{.FeedURL}}.rss">
|
||||
{{end}}
|
||||
<link rel="icon" href="{{AssetUrlPrefix}}/img/favicon.svg" type="image/svg+xml">
|
||||
<link rel="alternate icon" href="{{AssetUrlPrefix}}/img/favicon.png" type="image/png">
|
||||
<script>
|
||||
window.config = {
|
||||
appUrl: '{{AppUrl}}',
|
||||
appSubUrl: '{{AppSubUrl}}',
|
||||
assetVersionEncoded: encodeURIComponent('{{AssetVersion}}'), // will be used in URL construction directly
|
||||
assetUrlPrefix: '{{AssetUrlPrefix}}',
|
||||
runModeIsProd: {{.RunModeIsProd}},
|
||||
customEmojis: {{CustomEmojis}},
|
||||
csrfToken: '{{.CsrfToken}}',
|
||||
pageData: {{.PageData}},
|
||||
{{if or .Participants .Assignees .MentionableTeams}}
|
||||
mentionValues: Array.from(new Map([
|
||||
{{- range .Participants -}}
|
||||
['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}', name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink $.Context}}'}],
|
||||
{{- end -}}
|
||||
{{- range .Assignees -}}
|
||||
['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}', name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink $.Context}}'}],
|
||||
{{- end -}}
|
||||
{{- range .MentionableTeams -}}
|
||||
['{{$.MentionableTeamsOrg}}/{{.Name}}', {key: '{{$.MentionableTeamsOrg}}/{{.Name}}', value: '{{$.MentionableTeamsOrg}}/{{.Name}}', name: '{{$.MentionableTeamsOrg}}/{{.Name}}', avatar: '{{$.MentionableTeamsOrgAvatar}}'}],
|
||||
{{- end -}}
|
||||
]).values()),
|
||||
{{end}}
|
||||
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
|
||||
{{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}}
|
||||
i18n: {
|
||||
copy_success: {{ctx.Locale.Tr "copy_success"}},
|
||||
copy_error: {{ctx.Locale.Tr "copy_error"}},
|
||||
error_occurred: {{ctx.Locale.Tr "error.occurred"}},
|
||||
network_error: {{ctx.Locale.Tr "error.network_error"}},
|
||||
remove_label_str: {{ctx.Locale.Tr "remove_label_str"}},
|
||||
modal_confirm: {{ctx.Locale.Tr "modal.confirm"}},
|
||||
modal_cancel: {{ctx.Locale.Tr "modal.cancel"}},
|
||||
more_items: {{ctx.Locale.Tr "more_items"}},
|
||||
},
|
||||
};
|
||||
{{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}}
|
||||
window.config.pageData = window.config.pageData || {};
|
||||
</script>
|
||||
<script src="{{AssetUrlPrefix}}/js/webcomponents.js?v={{AssetVersion}}"></script>
|
||||
<noscript>
|
||||
<style>
|
||||
.dropdown:hover > .menu { display: block; }
|
||||
.ui.secondary.menu .dropdown.item > .menu { margin-top: 0; }
|
||||
</style>
|
||||
</noscript>
|
||||
{{template "base/head_opengraph" .}}
|
||||
{{template "base/head_style" .}}
|
||||
</head>
|
||||
|
||||
<body hx-headers='{"x-csrf-token": "{{.CsrfToken}}"}' hx-swap="outerHTML" hx-ext="morph" hx-push-url="false">
|
||||
|
||||
<div class="full height">
|
||||
<noscript>{{ctx.Locale.Tr "enable_javascript"}}</noscript>
|
||||
|
||||
{{template "custom/body_inner_pre" .}}
|
||||
|
||||
{{$notificationUnreadCount := 0}}
|
||||
{{if and .IsSigned .NotificationUnreadCount}}
|
||||
{{$notificationUnreadCount = call .NotificationUnreadCount}}
|
||||
{{end}}
|
||||
|
||||
<nav id="navbar" aria-label="{{ctx.Locale.Tr "aria.navbar"}}">
|
||||
<div class="navbar-left">
|
||||
<!-- the logo -->
|
||||
<a class="item" id="navbar-logo" href="{{AppSubUrl}}/" aria-label="{{if .IsSigned}}{{ctx.Locale.Tr "dashboard"}}{{else}}{{ctx.Locale.Tr "home"}}{{end}}">
|
||||
<img width="auto" height="30" src="{{AssetUrlPrefix}}/img/logo.svg" alt="{{ctx.Locale.Tr "logo"}}" aria-hidden="true">
|
||||
</a>
|
||||
|
||||
<!-- mobile right menu, it must be here because in mobile view, each item is a flex column, the first item is a full row column -->
|
||||
<div class="ui secondary menu item navbar-mobile-right only-mobile">
|
||||
{{if .IsSigned}}
|
||||
<a id="mobile-notifications-icon" class="item tw-w-auto tw-p-2" href="{{AppSubUrl}}/notifications" data-tooltip-content="{{ctx.Locale.Tr "notifications"}}" aria-label="{{ctx.Locale.Tr "notifications"}}">
|
||||
<div class="tw-relative">
|
||||
{{svg "octicon-bell"}}
|
||||
<span class="notification_count{{if not $notificationUnreadCount}} tw-hidden{{end}}">{{$notificationUnreadCount}}</span>
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
<button class="item tw-w-auto ui icon mini button tw-p-2 tw-m-0" id="navbar-expand-toggle" aria-label="{{ctx.Locale.Tr "home.nav_menu"}}">{{svg "octicon-three-bars"}}</button>
|
||||
</div>
|
||||
|
||||
<!-- navbar links non-mobile -->
|
||||
{{template "custom/extra_links" .}}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- the full dropdown menus -->
|
||||
<div class="navbar-right">
|
||||
|
||||
<!-- TODO: 注册触发外部链接 -->
|
||||
|
||||
<!-- <a id="signup-link" class="item active" href="{{AppSubUrl}}/user/sign_up">-->
|
||||
<!-- {{svg "octicon-person"}} {{ctx.Locale.Tr "register"}}-->
|
||||
<!-- </a>-->
|
||||
<a id="signin-link" class="item active" rel="nofollow" href="javascript:openLoginModal()">
|
||||
{{svg "octicon-sign-in"}} {{ctx.Locale.Tr "sign_in"}}
|
||||
</a>
|
||||
<a id="logout-link" class="item active" href="javascript:logout()">
|
||||
{{svg "octicon-sign-out"}}
|
||||
{{ctx.Locale.Tr "sign_out"}}
|
||||
</a>
|
||||
</div><!-- end full right menu -->
|
||||
|
||||
</nav>
|
||||
|
||||
|
||||
<div role="main" aria-label="{{if .IsSigned}}{{ctx.Locale.Tr "dashboard"}}{{else}}{{ctx.Locale.Tr "home"}}{{end}}" class="page-content home">
|
||||
|
||||
<div class="ui middle very relaxed page">
|
||||
|
||||
<!-- login -->
|
||||
<div id="loginModal" class="ui modal" style=" width:50%; left: 25%; top: 20%">
|
||||
<div class="content">
|
||||
<div class="close right" style="float: right" onclick="closeLoginModal()">X</div>
|
||||
<div class="ui container column fluid">
|
||||
<h2 class="ui top attached header center i18n" data-key="login_modal"></h2>
|
||||
<div class="ui attached segment">
|
||||
<form id="loginForm" class="ui form" action="javascript:login()">
|
||||
<div class="required field">
|
||||
<label for="username" class="i18n" data-key="username"></label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="required field">
|
||||
<label for="password" class="i18n" data-key="password"></label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<button class="ui button primary center i18n" data-key="login_button"></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="column">
|
||||
<div id="contentBeforeSigned" class="ui container" style="display: none">
|
||||
<h3 class="header center i18n" data-key="login_reminder"></h3>
|
||||
</div>
|
||||
<div id="contentAfterSigned" class="ui container " style="display: none">
|
||||
<button class="ui button primary i18n" data-key="createNewRepositoryButton" onclick="openModalForCreateRepository()"></button>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- 仓库列表 -->
|
||||
<div id="repo_list" class="flex-list">
|
||||
<!-- 动态加载-->
|
||||
</div>
|
||||
|
||||
<!-- ====================== Created Repository ==========================-->
|
||||
<div id="modalForCreatingRepo" class="ui modal" style="width:70%; left: 15%; top: 20%">
|
||||
<div class="content">
|
||||
<div class="close right" style="float: right" onclick="closeModalForCreatingRepo()">X</div>
|
||||
<div class="ui container column fluid" style="height: 50%;">
|
||||
<h2 class="ui top attached header center i18n" data-key="createNewRepositoryModal"></h2>
|
||||
<div class="ui attached segment" >
|
||||
<form id="creatingRepoForm" class="ui form" onsubmit="return createRepo()">
|
||||
<!-- repo name -->
|
||||
<div class="inline required field {{if .Err_RepoName}}error{{end}}">
|
||||
<label for="repo_name">{{ctx.Locale.Tr "repo.repo_name"}}</label>
|
||||
<input id="repo_name" name="repo_name" value="{{.repo_name}}" autofocus required maxlength="100">
|
||||
<span class="help">{{ctx.Locale.Tr "repo.repo_name_helper"}}</span>
|
||||
</div>
|
||||
|
||||
<!-- visibility -->
|
||||
<!-- <div class="inline field">-->
|
||||
<!-- <label>{{ctx.Locale.Tr "repo.visibility"}}</label>-->
|
||||
<!-- <div class="ui checkbox">-->
|
||||
<!-- <input id="is_private" name="private" type="checkbox" >-->
|
||||
<!-- <label>{{ctx.Locale.Tr "repo.visibility_helper"}}</label>-->
|
||||
<!-- </div>-->
|
||||
<!-- <span class="help">{{ctx.Locale.Tr "repo.visibility_description"}}</span>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- desc -->
|
||||
<div class="inline field {{if .Err_Description}}error{{end}}">
|
||||
<label for="description">{{ctx.Locale.Tr "repo.repo_desc"}}</label>
|
||||
<textarea id="description" rows="2" name="description" placeholder="{{ctx.Locale.Tr "repo.repo_desc_helper"}}" maxlength="2048">{{.description}}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- template -->
|
||||
|
||||
<div id="devstar_template_area" class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.devstar_template"}}</label>
|
||||
<div id="devstar_template_search" class="ui search selection dropdown">
|
||||
<input type="hidden" id="devstar_template" name="devstar_template" value="">
|
||||
<div id="devstar_template_name" class="default text">{{.devstar_template_name}}</div>
|
||||
<div class="menu">
|
||||
</div>
|
||||
</div>
|
||||
<span class="help">{{ctx.Locale.Tr "repo.devstar_template_desc" "https://DevStar.cn/"}}</span>
|
||||
</div>
|
||||
|
||||
<div id="git_url_template_area" class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.git_url_template"}}</label>
|
||||
<input id="git_url_template" name="git_url_template" value="" placeholder="{{ctx.Locale.Tr "repo.git_url_template_helper"}}">
|
||||
<span class="help">{{ctx.Locale.Tr "repo.git_url_template_desc"}}</span>
|
||||
</div>
|
||||
|
||||
<div id="repo_template_area">
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.template"}}</label>
|
||||
<div id="repo_template_search" class="ui search selection dropdown">
|
||||
<input type="hidden" id="repo_template" name="repo_template" value="">
|
||||
<div id="repo_template_name" class="default text">{{.devstar_template_name}}</div>
|
||||
<div class="menu">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="template_units" {{if not .repo_template}}class="tw-hidden"{{end}}>
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.template.items"}}</label>
|
||||
<div class="ui checkbox">
|
||||
<input name="git_content" type="checkbox" {{if .git_content}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.template.git_content"}}</label>
|
||||
</div>
|
||||
<div class="ui checkbox" {{if not .SignedUser.CanEditGitHook}}data-tooltip-content="{{ctx.Locale.Tr "repo.template.git_hooks_tooltip"}}"{{end}}>
|
||||
<input name="git_hooks" type="checkbox" {{if .git_hooks}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.template.git_hooks"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inline field">
|
||||
<label></label>
|
||||
<div class="ui checkbox">
|
||||
<input name="webhooks" type="checkbox" {{if .webhooks}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.template.webhooks"}}</label>
|
||||
</div>
|
||||
<div class="ui checkbox">
|
||||
<input name="topics" type="checkbox" {{if .topics}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.template.topics"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<label></label>
|
||||
<div class="ui checkbox">
|
||||
<input name="avatar" type="checkbox" {{if .avatar}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.template.avatar"}}</label>
|
||||
</div>
|
||||
<div class="ui checkbox">
|
||||
<input name="labels" type="checkbox" {{if .labels}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.template.issue_labels"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<label></label>
|
||||
<div class="ui checkbox">
|
||||
<input name="protected_branch" type="checkbox" {{if .protected_branch}}checked{{end}}>
|
||||
<label>{{ctx.Locale.Tr "repo.settings.protected_branch"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="non_template" {{if .repo_template}}class="tw-hidden"{{end}}>
|
||||
<!-- issue label -->
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.issue_labels"}}</label>
|
||||
<div class="ui search selection dropdown">
|
||||
<input id="issue_label" type="hidden" name="issue_labels" value="{{.issueLabels}}">
|
||||
<div class="default text">{{ctx.Locale.Tr "repo.issue_labels_helper"}}</div>
|
||||
<div id="issue_label_menu" class="menu">
|
||||
<div class="item" data-value="">{{ctx.Locale.Tr "repo.issue_labels_helper"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="inline field">
|
||||
<label>.gitignore</label>
|
||||
<div class="ui multiple search selection dropdown">
|
||||
<input id="gitignore" type="hidden" name="gitignores" value="{{.gitignores}}">
|
||||
<div class="default text">{{ctx.Locale.Tr "repo.repo_gitignore_helper"}}</div>
|
||||
<div id="gitignore_menu" class="menu">
|
||||
</div>
|
||||
</div>
|
||||
<span class="help">{{ctx.Locale.Tr "repo.repo_gitignore_helper_desc"}}</span>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.license"}}</label>
|
||||
<div class="ui search selection dropdown">
|
||||
<input id="license" type="hidden" name="license" value="{{.license}}">
|
||||
<div class="default text">{{ctx.Locale.Tr "repo.license_helper"}}</div>
|
||||
<div id="license_menu" class="menu">
|
||||
<div class="item" data-value="">{{ctx.Locale.Tr "repo.license_helper"}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="help">{{ctx.Locale.Tr "repo.license_helper_desc" "https://choosealicense.com/"}}</span>
|
||||
</div>
|
||||
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.readme"}}</label>
|
||||
<div class="ui selection dropdown">
|
||||
<input id="readme" type="hidden" name="readme" value="{{.readme}}">
|
||||
<div class="default text">{{ctx.Locale.Tr "repo.readme_helper"}}</div>
|
||||
<div id="readme_menu" class="menu">
|
||||
{{range .Readmes}}
|
||||
<div class="item" data-value="{{.}}">{{.}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<span class="help">{{ctx.Locale.Tr "repo.readme_helper_desc"}}</span>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<label for="default_branch">{{ctx.Locale.Tr "repo.default_branch"}}</label>
|
||||
<input id="default_branch" name="default_branch" value="{{.default_branch}}" placeholder="{{.default_branch}}">
|
||||
<span class="help">{{ctx.Locale.Tr "repo.default_branch_helper"}}</span>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.object_format"}}</label>
|
||||
<div class="ui selection owner dropdown">
|
||||
<input type="hidden" id="object_format_name" name="object_format_name" value="{{.DefaultObjectFormat.Name}}" required>
|
||||
<div id="default_object_format_name" class="default text">{{.DefaultObjectFormat.Name}}</div>
|
||||
<div id="object_format_menu" class="menu">
|
||||
{{range .SupportedObjectFormats}}
|
||||
<div class="item" data-value="{{.Name}}">{{.Name}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<span class="help">{{ctx.Locale.Tr "repo.object_format_helper"}}</span>
|
||||
</div>
|
||||
<div class="inline field">
|
||||
<label>{{ctx.Locale.Tr "repo.template"}}</label>
|
||||
<div class="ui checkbox">
|
||||
<input id="is_template" name="template" type="checkbox">
|
||||
<label>{{ctx.Locale.Tr "repo.template_helper"}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div class="inline field">
|
||||
<label></label>
|
||||
<button class="ui primary button" >
|
||||
{{ctx.Locale.Tr "repo.create_repo"}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<!-- footer start -->
|
||||
{{if false}}
|
||||
{{/* to make html structure "likely" complete to prevent IDE warnings */}}
|
||||
<html>
|
||||
<body>
|
||||
<div>
|
||||
{{end}}
|
||||
|
||||
{{template "custom/body_inner_post" .}}
|
||||
|
||||
</div>
|
||||
|
||||
{{template "custom/body_outer_post" .}}
|
||||
|
||||
<!-- content from {{template "base/footer_content" .}} -->
|
||||
<footer class="page-footer" role="group" aria-label="{{ctx.Locale.Tr "aria.footer"}}">
|
||||
<div class="left-links" role="contentinfo" aria-label="{{ctx.Locale.Tr "aria.footer.software"}}">
|
||||
{{if (or .ShowFooterVersion .PageIsAdmin)}}
|
||||
{{ctx.Locale.Tr "version"}}:
|
||||
{{if .IsAdmin}}
|
||||
<a href="{{AppSubUrl}}/admin/config">{{AppVer}}</a>
|
||||
{{else}}
|
||||
{{AppVer}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if and .TemplateLoadTimes ShowFooterTemplateLoadTime}}
|
||||
{{ctx.Locale.Tr "page"}}: <strong>{{LoadTimes .PageStartTime}}</strong>
|
||||
{{ctx.Locale.Tr "template"}}{{if .TemplateName}} {{.TemplateName}}{{end}}: <strong>{{call .TemplateLoadTimes}}</strong>
|
||||
{{end}}
|
||||
<p> © 2025 <a target="_blank" rel="noopener noreferrer" href="https://www.mengning.com.cn">Mengning Software</a>. All rights reserved.</p>
|
||||
</div>
|
||||
<div class="right-links" role="group" aria-label="{{ctx.Locale.Tr "aria.footer.links"}}">
|
||||
<!-- 不支持切换语言 -->
|
||||
<div class="ui dropdown upward language">
|
||||
<span class="flex-text-inline">{{svg "octicon-globe" 14}} {{ctx.Locale.LangName}}</span>
|
||||
<!-- <div class="menu language-menu">
|
||||
{{range .AllLangs}}
|
||||
<a lang="{{.Lang}}" data-url="{{AppSubUrl}}/?lang={{.Lang}}" class="item {{if eq ctx.Locale.Lang .Lang}}active selected{{end}}">{{.Name}}</a>
|
||||
{{end}}
|
||||
</div> -->
|
||||
</div>
|
||||
<a href="{{AssetUrlPrefix}}/licenses.txt">{{ctx.Locale.Tr "licenses"}}</a>
|
||||
{{if ShowFooterPoweredBy}}
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://github.com/mengning/DevStar">{{ctx.Locale.Tr "powered_by" "DevStar"}}</a>
|
||||
{{end}}
|
||||
{{if .EnableSwagger}}<a id="apiswagger" href="{{AppSubUrl}}/api/swagger">API</a>{{end}}
|
||||
<!-- {{template "custom/extra_links_footer" .}} -->
|
||||
<a id="beian" href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">苏ICP备2024068144号-3</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{AssetUrlPrefix}}/js/index.js?v={{AssetVersion}}" onerror="alert('Failed to load asset files from ' + this.src + '. Please make sure the asset files can be accessed.')"></script>
|
||||
|
||||
{{template "custom/footer" .}}
|
||||
</body>
|
||||
</html>
|
||||
<!-- footer end -->
|
||||
|
||||
{{template "repo/devcontainer/vscode-home-js" .}}
|
||||
@@ -141,6 +141,15 @@
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
<!-- 定义DevContainer tab -->
|
||||
{{if .AllowCreateDevcontainer }}
|
||||
{{if .Permission.CanRead ctx.Consts.RepoUnitTypeCode}}
|
||||
<a class="{{if .PageIsDevContainer}}active {{end}}item" href="{{.RepoLink}}/devcontainer">
|
||||
{{svg "octicon-container"}} {{ctx.Locale.Tr "repo.dev_container"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .Permission.CanRead ctx.Consts.RepoUnitTypeIssues}}
|
||||
<a class="{{if .PageIsIssueList}}active {{end}}item" href="{{.RepoLink}}/issues">
|
||||
{{svg "octicon-issue-opened"}} {{ctx.Locale.Tr "repo.issues"}}
|
||||
|
||||
70
webTerminal.sh
Normal file
70
webTerminal.sh
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# 获取参数
|
||||
ACTION=$1
|
||||
OS_ID=$(grep '^ID=' /etc/os-release | cut -d= -f2 | tr -d '"')
|
||||
|
||||
|
||||
|
||||
# 根据参数执行不同命令
|
||||
case $ACTION in
|
||||
"start")
|
||||
echo "Starting service..."
|
||||
# 启动服务的命令
|
||||
echo "$DevstarHost host.docker.internal" | tee -a /etc/hosts;
|
||||
case $OS_ID in
|
||||
ubuntu|debian)
|
||||
apt-get update -y
|
||||
apt-get install ssh git -y
|
||||
;;
|
||||
centos)
|
||||
# sudo yum update -y
|
||||
# sudo yum install -y epel-release
|
||||
# sudo yum groupinstall -y "Development Tools"
|
||||
# sudo yum install -y yaml-cpp yaml-cpp-devel
|
||||
;;
|
||||
fedora)
|
||||
# sudo dnf update -y
|
||||
# sudo dnf group install -y "Development Tools"
|
||||
# sudo dnf install -y yaml-cpp yaml-cpp-devel
|
||||
;;
|
||||
*)
|
||||
failure "Unsupported OS: $OS_ID"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo -e "PubkeyAuthentication yes\nPermitRootLogin yes\n" | tee -a /etc/ssh/sshd_config;
|
||||
rm -f /etc/ssh/ssh_host_*;
|
||||
ssh-keygen -A;
|
||||
mkdir -p ~/.ssh;
|
||||
chmod 700 ~/.ssh;
|
||||
case $OS_ID in
|
||||
ubuntu|debian)
|
||||
service ssh restart;
|
||||
;;
|
||||
centos)
|
||||
;;
|
||||
fedora)
|
||||
;;
|
||||
*)
|
||||
failure "Unsupported OS: $OS_ID"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "$PublicKeyList" > ~/.ssh/authorized_keys;
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
git clone $RepoLink $WorkSpace
|
||||
;;
|
||||
"stop")
|
||||
echo "Stopping service..."
|
||||
# 停止服务的命令
|
||||
;;
|
||||
"restart")
|
||||
echo "Restarting service..."
|
||||
# 重启服务的命令
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|restart}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user