├── .github └── workflows │ ├── codecov.yml │ ├── publish_npm.yaml │ ├── push_test_server.yml │ ├── release.yml │ └── sync_openapi.yml ├── .gitignore ├── .goreleaser.debug.yaml ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── api ├── app_management │ ├── openapi.yaml │ └── openapi_v1.yaml ├── index.html └── index_v1.html ├── build ├── scripts │ ├── migration │ │ ├── script.d │ │ │ └── 06-migrate-app-management.sh │ │ └── service.d │ │ │ └── app-management │ │ │ └── migration.list │ └── setup │ │ ├── script.d │ │ └── 06-setup-app-management.sh │ │ └── service.d │ │ └── app-management │ │ ├── arch │ │ └── setup-app-management.sh │ │ ├── debian │ │ ├── bullseye │ │ │ └── setup-app-management.sh │ │ └── setup-app-management.sh │ │ └── ubuntu │ │ ├── jammy │ │ └── setup-app-management.sh │ │ └── setup-app-management.sh └── sysroot │ ├── etc │ └── casaos │ │ ├── app-management.conf.sample │ │ └── env │ └── usr │ ├── lib │ └── systemd │ │ └── system │ │ ├── casaos-app-management.service │ │ └── casaos-app-management.service.buildroot │ └── share │ └── casaos │ └── cleanup │ ├── script.d │ └── 06-cleanup-app-management.sh │ └── service.d │ └── app-management │ ├── arch │ └── cleanup-app-management.sh │ ├── debian │ ├── bullseye │ │ └── cleanup-app-management.sh │ └── cleanup-app-management.sh │ └── ubuntu │ ├── cleanup-app-management.sh │ └── jammy │ └── cleanup-app-management.sh ├── cmd ├── appfile2compose │ ├── log.go │ └── main.go ├── message-bus-docgen │ └── main.go ├── migration-tool │ ├── log.go │ ├── main.go │ ├── migration_0412_and_older.go │ ├── migration_0415_and_older.go │ └── migration_dummy.go └── validator │ ├── log.go │ ├── main.go │ └── pkg │ ├── validate.go │ └── validator_test.go ├── common ├── constants.go ├── context_properties.go ├── fixtures │ ├── sample-appfile-export.json │ ├── sample-category-list.json │ ├── sample-vanilla.docker-compose.yaml │ └── sample.docker-compose.yaml ├── message.go └── test.go ├── go.mod ├── go.sum ├── main.go ├── model ├── app.go ├── category.go ├── docker.go ├── manifest.go ├── manifest_adapter.go ├── manifest_adapter_test.go └── sys_common.go ├── package.json ├── pkg ├── config │ ├── config.go │ └── init.go ├── docker │ ├── auth.go │ ├── container.go │ ├── container_test.go │ ├── daemon.go │ ├── daemon_test.go │ ├── digest.go │ ├── digest_test.go │ ├── emum.go │ ├── helpers.go │ ├── image.go │ ├── manifest.go │ ├── registry.go │ ├── trust.go │ ├── volumes.go │ └── volumes_test.go └── utils │ ├── cpu.go │ ├── downloadHelper │ ├── getter.go │ └── getter_test.go │ └── envHelper │ └── env.go ├── route ├── v1.go ├── v1 │ ├── docker.go │ └── route.go ├── v2.go └── v2 │ ├── appstore.go │ ├── appstore_test.go │ ├── compose_app.go │ ├── convert.go │ ├── docker.go │ ├── global.go │ ├── image.go │ ├── info.go │ ├── internal_web.go │ ├── internal_web_test.go │ └── route.go ├── service ├── app.go ├── appstore.go ├── appstore_management.go ├── appstore_management_test.go ├── appstore_test.go ├── compose_app.go ├── compose_app_test.go ├── compose_service.go ├── container.go ├── errs.go ├── image.go ├── service.go ├── service_test.go ├── utils.go └── v1 │ └── app.go └── tsconfig.json /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: Collect Code Coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: "1.21" 23 | - name: Go Generate 24 | run: go generate 25 | - name: Run coverage 26 | run: go test -race -failfast -coverprofile=coverage.txt -covermode=atomic -v ./... 27 | - name: Upload coverage to Codecov 28 | uses: codecov/codecov-action@v3 29 | -------------------------------------------------------------------------------- /.github/workflows/publish_npm.yaml: -------------------------------------------------------------------------------- 1 | name: publish npm 2 | 3 | on: 4 | push: 5 | tags: 6 | - v*.*.* 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | publish-npm: 14 | runs-on: ubuntu-22.04 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: 16 20 | registry-url: https://registry.npmjs.org/ 21 | - name: Get version 22 | id: get_version 23 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 24 | - name: Set version 25 | run: | 26 | sudo apt-get install jq 27 | jq '.version="${{ steps.get_version.outputs.VERSION }}"' package.json > package.json.new 28 | mv package.json.new package.json 29 | - name: Generate SDK 30 | run: | 31 | npm cache clean --force 32 | npm install @openapitools/openapi-generator-cli -g 33 | make build 34 | - run: npm i 35 | - run: npm run start 36 | - run: npm publish 37 | env: 38 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 39 | -------------------------------------------------------------------------------- /.github/workflows/push_test_server.yml: -------------------------------------------------------------------------------- 1 | name: Auto Publish Website 2 | on: 3 | push: 4 | branches: 5 | - main 6 | permissions: 7 | contents: write 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-22.04 11 | steps: 12 | - 13 | name: Checkout 14 | uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | - name: isntall git 18 | run: sudo apt install --yes git 19 | - name: git global 20 | run: sudo git config --global --add safe.directory '*' 21 | - name: set version 22 | run: sudo git tag v99.99.99-alpha 23 | - 24 | name: Fetch all tags 25 | run: sudo git fetch --force --tags 26 | - name: Get version 27 | id: get_version 28 | # run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 29 | run: echo "VERSION=$(git describe --abbrev=0 --tags | awk -F- '{print $1}')" >> $GITHUB_ENV 30 | - name: show version 31 | id: show_version 32 | # run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 33 | run: echo ${{env.VERSION}} 34 | - 35 | name: Set up Go 36 | uses: actions/setup-go@v4 37 | with: 38 | go-version: '1.21' 39 | - 40 | name: Run GoReleaser 41 | uses: goreleaser/goreleaser-action@v4 42 | with: 43 | # either 'goreleaser' (default) or 'goreleaser-pro' 44 | distribution: goreleaser 45 | version: 1.14.1 46 | args: release --rm-dist --snapshot 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution 50 | # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} 51 | 52 | - name: remove migration file 53 | run: find . -type f \( -name '*migration*' \) -delete 54 | 55 | - name: install sshpass 56 | run: sudo apt install sshpass --yes 57 | - name: ZeroTier 58 | uses: zerotier/github-action@v1.0.1 59 | with: 60 | network_id: ${{ secrets.ZEROTIER_NETWORK_ID }} 61 | auth_token: ${{ secrets.ZEROTIER_CENTRAL_TOKEN }} 62 | 63 | - name: ping host 64 | shell: bash 65 | run: | 66 | count=10 67 | while ! ping -c 1 10.147.18.11 ; do 68 | echo "waiting..." ; 69 | sleep 1 ; 70 | let count=count-1 71 | done 72 | echo "ping success" 73 | 74 | - name: copy tar to target host 75 | shell: bash 76 | run: | 77 | sshpass -p "${{ secrets.ssh_password }}" scp -r -o StrictHostKeyChecking=no -P 22 ./dist/*.gz root@10.147.18.11:/var/www/download 78 | echo "ping success" 79 | # - name: send message 80 | # run: | 81 | # curl -X POST -H "Content-Type: application/json" -d '{"msg_type":"text","content":{"text":"CasaOS-AppManagement updated"}}' ${{ secrets.SSH_ROBOT_URL }} 82 | - name: Error Handling 83 | if: ${{ failure() }} 84 | run: | 85 | curl -X POST -H "Content-Type: application/json" -d '{"msg_type":"text","content":{"text":"CasaOS-AppManagement push Error"}}' ${{ secrets.SSH_ROBOT_URL }} 86 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - v*.*.* 7 | env: 8 | casaos_cache_version: 1.1 # the value define by causal. the field for to clear the GitHub Action cache 9 | 10 | permissions: 11 | contents: write 12 | jobs: 13 | call-workflow-passing-data: 14 | uses: IceWhaleTech/github/.github/workflows/go_release.yml@main 15 | with: 16 | project-name: CasaOS-AppManagement 17 | file-name: casaos-app-management 18 | secrets: 19 | OSS_KEY_ID: ${{ secrets.OSS_KEY_ID }} 20 | OSS_KEY_SECRET: ${{ secrets.OSS_KEY_SECRET }} -------------------------------------------------------------------------------- /.github/workflows/sync_openapi.yml: -------------------------------------------------------------------------------- 1 | name: Sync OpenAPI 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | project-name: 7 | required: true 8 | type: string 9 | push: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | sync: 15 | uses: IceWhaleTech/github/.github/workflows/sync_openapi.yml@main 16 | with: 17 | project-name: casaos-app-management 18 | secrets: 19 | API_TOKEN_GITHUB: ${{ secrets.API_TOKEN_GITHUB }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | .vscode/ 17 | dist/ 18 | target/ 19 | codegen/ 20 | __debug_bin 21 | CasaOS-AppManagement 22 | 23 | # node_modules 24 | node_modules 25 | package-lock.json 26 | 27 | .idea 28 | build/sysroot/usr/bin/appfile2compose 29 | -------------------------------------------------------------------------------- /.goreleaser.debug.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | project_name: casaos-app-management 4 | before: 5 | hooks: 6 | - go generate 7 | - go run github.com/google/go-licenses@latest check . --disallowed_types=restricted 8 | - go mod tidy 9 | - go test -race -v ./... -timeout 30m 10 | builds: 11 | - id: casaos-app-management-amd64 12 | binary: build/sysroot/usr/bin/casaos-app-management 13 | env: 14 | - CC=x86_64-linux-gnu-gcc 15 | gcflags: 16 | - all=-N -l 17 | ldflags: 18 | - -X main.commit={{.Commit}} 19 | - -X main.date={{.Date}} 20 | - -extldflags "-static" 21 | tags: 22 | - musl 23 | - netgo 24 | - osusergo 25 | goos: 26 | - linux 27 | goarch: 28 | - amd64 29 | hooks: 30 | post: 31 | - cmd: go build -v -tags 'musl netgo osusergo' -o build/sysroot/usr/bin/appfile2compose github.com/IceWhaleTech/CasaOS-AppManagement/cmd/appfile2compose 32 | output: true 33 | - id: casaos-app-management-arm64 34 | binary: build/sysroot/usr/bin/casaos-app-management 35 | env: 36 | - CC=aarch64-linux-gnu-gcc 37 | gcflags: 38 | - all=-N -l 39 | ldflags: 40 | - -X main.commit={{.Commit}} 41 | - -X main.date={{.Date}} 42 | - -extldflags "-static" 43 | tags: 44 | - musl 45 | - netgo 46 | - osusergo 47 | goos: 48 | - linux 49 | goarch: 50 | - arm64 51 | hooks: 52 | post: 53 | - cmd: go build -v -tags 'musl netgo osusergo' -o build/sysroot/usr/bin/appfile2compose github.com/IceWhaleTech/CasaOS-AppManagement/cmd/appfile2compose 54 | output: true 55 | - id: casaos-app-management-arm-7 56 | binary: build/sysroot/usr/bin/casaos-app-management 57 | env: 58 | - CC=arm-linux-gnueabihf-gcc 59 | gcflags: 60 | - all=-N -l 61 | ldflags: 62 | - -X main.commit={{.Commit}} 63 | - -X main.date={{.Date}} 64 | - -extldflags "-static" 65 | tags: 66 | - musl 67 | - netgo 68 | - osusergo 69 | goos: 70 | - linux 71 | goarch: 72 | - arm 73 | goarm: 74 | - "7" 75 | hooks: 76 | post: 77 | - cmd: go build -v -tags 'musl netgo osusergo' -o build/sysroot/usr/bin/appfile2compose github.com/IceWhaleTech/CasaOS-AppManagement/cmd/appfile2compose 78 | output: true 79 | - id: casaos-app-management-riscv64 80 | binary: build/sysroot/usr/bin/casaos-app-management 81 | env: 82 | - CC=riscv64-linux-gnu-gcc 83 | gcflags: 84 | - all=-N -l 85 | ldflags: 86 | - -X main.commit={{.Commit}} 87 | - -X main.date={{.Date}} 88 | - -extldflags "-static" 89 | tags: 90 | - musl 91 | - netgo 92 | - osusergo 93 | goos: 94 | - linux 95 | goarch: 96 | - riscv64 97 | hooks: 98 | post: 99 | - cmd: go build -v -tags 'musl netgo osusergo' -o build/sysroot/usr/bin/appfile2compose github.com/IceWhaleTech/CasaOS-AppManagement/cmd/appfile2compose 100 | output: true 101 | - id: casaos-app-management-migration-tool-amd64 102 | binary: build/sysroot/usr/bin/casaos-app-management-migration-tool 103 | main: ./cmd/migration-tool 104 | env: 105 | - CC=x86_64-linux-gnu-gcc 106 | gcflags: 107 | - all=-N -l 108 | ldflags: 109 | - -X main.commit={{.Commit}} 110 | - -X main.date={{.Date}} 111 | - -extldflags "-static" 112 | tags: 113 | - musl 114 | - netgo 115 | - osusergo 116 | goos: 117 | - linux 118 | goarch: 119 | - amd64 120 | - id: casaos-app-management-migration-tool-arm64 121 | binary: build/sysroot/usr/bin/casaos-app-management-migration-tool 122 | main: ./cmd/migration-tool 123 | env: 124 | - CC=aarch64-linux-gnu-gcc 125 | gcflags: 126 | - all=-N -l 127 | ldflags: 128 | - -X main.commit={{.Commit}} 129 | - -X main.date={{.Date}} 130 | - -extldflags "-static" 131 | tags: 132 | - musl 133 | - netgo 134 | - osusergo 135 | goos: 136 | - linux 137 | goarch: 138 | - arm64 139 | - id: casaos-app-management-migration-tool-arm-7 140 | binary: build/sysroot/usr/bin/casaos-app-management-migration-tool 141 | main: ./cmd/migration-tool 142 | env: 143 | - CC=arm-linux-gnueabihf-gcc 144 | gcflags: 145 | - all=-N -l 146 | ldflags: 147 | - -X main.commit={{.Commit}} 148 | - -X main.date={{.Date}} 149 | - -extldflags "-static" 150 | tags: 151 | - musl 152 | - netgo 153 | - osusergo 154 | goos: 155 | - linux 156 | goarch: 157 | - arm 158 | goarm: 159 | - "7" 160 | - id: casaos-app-management-migration-tool-riscv64 161 | binary: build/sysroot/usr/bin/casaos-app-management-migration-tool 162 | main: ./cmd/migration-tool 163 | env: 164 | - CC=riscv64-linux-gnu-gcc 165 | gcflags: 166 | - all=-N -l 167 | ldflags: 168 | - -X main.commit={{.Commit}} 169 | - -X main.date={{.Date}} 170 | - -extldflags "-static" 171 | tags: 172 | - musl 173 | - netgo 174 | - osusergo 175 | goos: 176 | - linux 177 | goarch: 178 | - riscv64 179 | archives: 180 | - name_template: >- 181 | {{ .Os }}-{{- if eq .Arch "arm" }}arm-7{{- else }}{{ .Arch }}{{- end }}-{{ .ProjectName }}-v{{ .Version }} 182 | id: casaos-app-management 183 | builds: 184 | - casaos-app-management-amd64 185 | - casaos-app-management-arm64 186 | - casaos-app-management-arm-7 187 | - casaos-app-management-riscv64 188 | files: 189 | - build/**/* 190 | - name_template: >- 191 | {{ .Os }}-{{- if eq .Arch "arm" }}arm-7{{- else }}{{ .Arch }}{{- end }}-{{ .ProjectName }}-migration-tool-v{{ .Version }} 192 | id: casaos-app-management-migration-tool 193 | builds: 194 | - casaos-app-management-migration-tool-amd64 195 | - casaos-app-management-migration-tool-arm64 196 | - casaos-app-management-migration-tool-arm-7 197 | - casaos-app-management-migration-tool-riscv64 198 | files: 199 | - build/sysroot/etc/**/* 200 | checksum: 201 | name_template: "checksums.txt" 202 | snapshot: 203 | name_template: "{{ incpatch .Version }}-snapshot" 204 | changelog: 205 | sort: asc 206 | filters: 207 | exclude: 208 | - "^docs:" 209 | - "^test:" 210 | release: 211 | github: 212 | owner: IceWhaleTech 213 | name: CasaOS-AppManagement 214 | draft: true 215 | prerelease: auto 216 | mode: replace 217 | name_template: "v{{ .Version }}" 218 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | project_name: casaos-app-management 4 | before: 5 | hooks: 6 | - go generate 7 | - go run github.com/google/go-licenses@latest check . --disallowed_types=restricted 8 | - go mod tidy 9 | - go test -race -v ./... -timeout 30m 10 | builds: 11 | - id: casaos-app-management-amd64 12 | binary: build/sysroot/usr/bin/casaos-app-management 13 | hooks: 14 | post: 15 | - cmd: go build -v -tags 'musl netgo osusergo' -o build/sysroot/usr/bin/appfile2compose github.com/IceWhaleTech/CasaOS-AppManagement/cmd/appfile2compose 16 | output: true 17 | - cmd: upx --best --lzma -v --no-progress build/sysroot/usr/bin/appfile2compose 18 | output: true 19 | - cmd: upx --best --lzma -v --no-progress "{{ .Path }}" 20 | output: true 21 | env: 22 | - CC=x86_64-linux-gnu-gcc 23 | ldflags: 24 | - -X main.commit={{.Commit}} 25 | - -X main.date={{.Date}} 26 | - -s 27 | - -w 28 | - -extldflags "-static" 29 | tags: 30 | - musl 31 | - netgo 32 | - osusergo 33 | goos: 34 | - linux 35 | goarch: 36 | - amd64 37 | - id: casaos-app-management-arm64 38 | binary: build/sysroot/usr/bin/casaos-app-management 39 | hooks: 40 | post: 41 | - cmd: go build -v -tags 'musl netgo osusergo' -o build/sysroot/usr/bin/appfile2compose github.com/IceWhaleTech/CasaOS-AppManagement/cmd/appfile2compose 42 | output: true 43 | # - cmd: upx --best --lzma -v --no-progress build/sysroot/usr/bin/appfile2compose 44 | # output: true 45 | # - cmd: upx --best --lzma -v --no-progress "{{ .Path }}" 46 | # output: true 47 | env: 48 | - CC=aarch64-linux-gnu-gcc 49 | ldflags: 50 | - -X main.commit={{.Commit}} 51 | - -X main.date={{.Date}} 52 | - -s 53 | - -w 54 | - -extldflags "-static" 55 | tags: 56 | - musl 57 | - netgo 58 | - osusergo 59 | goos: 60 | - linux 61 | goarch: 62 | - arm64 63 | - id: casaos-app-management-arm-7 64 | binary: build/sysroot/usr/bin/casaos-app-management 65 | hooks: 66 | post: 67 | - cmd: go build -v -tags 'musl netgo osusergo' -o build/sysroot/usr/bin/appfile2compose github.com/IceWhaleTech/CasaOS-AppManagement/cmd/appfile2compose 68 | output: true 69 | - cmd: upx --best --lzma -v --no-progress build/sysroot/usr/bin/appfile2compose 70 | output: true 71 | - cmd: upx --best --lzma -v --no-progress "{{ .Path }}" 72 | output: true 73 | env: 74 | - CC=arm-linux-gnueabihf-gcc 75 | ldflags: 76 | - -X main.commit={{.Commit}} 77 | - -X main.date={{.Date}} 78 | - -s 79 | - -w 80 | - -extldflags "-static" 81 | tags: 82 | - musl 83 | - netgo 84 | - osusergo 85 | goos: 86 | - linux 87 | goarch: 88 | - arm 89 | goarm: 90 | - "7" 91 | - id: casaos-app-management-riscv64 92 | binary: build/sysroot/usr/bin/casaos-app-management 93 | hooks: 94 | post: 95 | - cmd: go build -v -tags 'musl netgo osusergo' -o build/sysroot/usr/bin/appfile2compose github.com/IceWhaleTech/CasaOS-AppManagement/cmd/appfile2compose 96 | output: true 97 | env: 98 | - CC=riscv64-linux-gnu-gcc 99 | ldflags: 100 | - -X main.commit={{.Commit}} 101 | - -X main.date={{.Date}} 102 | - -s 103 | - -w 104 | - -extldflags "-static" 105 | tags: 106 | - musl 107 | - netgo 108 | - osusergo 109 | goos: 110 | - linux 111 | goarch: 112 | - riscv64 113 | - id: casaos-app-management-migration-tool-amd64 114 | binary: build/sysroot/usr/bin/casaos-app-management-migration-tool 115 | hooks: 116 | post: 117 | - upx --best --lzma -v --no-progress "{{ .Path }}" 118 | main: ./cmd/migration-tool 119 | env: 120 | - CC=x86_64-linux-gnu-gcc 121 | ldflags: 122 | - -X main.commit={{.Commit}} 123 | - -X main.date={{.Date}} 124 | - -s 125 | - -w 126 | - -extldflags "-static" 127 | tags: 128 | - musl 129 | - netgo 130 | - osusergo 131 | goos: 132 | - linux 133 | goarch: 134 | - amd64 135 | - id: casaos-app-management-migration-tool-arm64 136 | binary: build/sysroot/usr/bin/casaos-app-management-migration-tool 137 | # hooks: 138 | # post: 139 | # - upx --best --lzma -v --no-progress "{{ .Path }}" 140 | main: ./cmd/migration-tool 141 | env: 142 | - CC=aarch64-linux-gnu-gcc 143 | ldflags: 144 | - -X main.commit={{.Commit}} 145 | - -X main.date={{.Date}} 146 | - -s 147 | - -w 148 | - -extldflags "-static" 149 | tags: 150 | - musl 151 | - netgo 152 | - osusergo 153 | goos: 154 | - linux 155 | goarch: 156 | - arm64 157 | - id: casaos-app-management-migration-tool-arm-7 158 | binary: build/sysroot/usr/bin/casaos-app-management-migration-tool 159 | hooks: 160 | post: 161 | - upx --best --lzma -v --no-progress "{{ .Path }}" 162 | main: ./cmd/migration-tool 163 | env: 164 | - CC=arm-linux-gnueabihf-gcc 165 | ldflags: 166 | - -X main.commit={{.Commit}} 167 | - -X main.date={{.Date}} 168 | - -s 169 | - -w 170 | - -extldflags "-static" 171 | tags: 172 | - musl 173 | - netgo 174 | - osusergo 175 | goos: 176 | - linux 177 | goarch: 178 | - arm 179 | goarm: 180 | - "7" 181 | - id: casaos-app-management-migration-tool-riscv64 182 | binary: build/sysroot/usr/bin/casaos-app-management-migration-tool 183 | main: ./cmd/migration-tool 184 | env: 185 | - CC=riscv64-linux-gnu-gcc 186 | ldflags: 187 | - -X main.commit={{.Commit}} 188 | - -X main.date={{.Date}} 189 | - -s 190 | - -w 191 | - -extldflags "-static" 192 | tags: 193 | - musl 194 | - netgo 195 | - osusergo 196 | goos: 197 | - linux 198 | goarch: 199 | - riscv64 200 | - id: casaos-app-management-validator-amd64 201 | binary: build/sysroot/usr/bin/casaos-app-management-validator 202 | hooks: 203 | post: 204 | - upx --best --lzma -v --no-progress "{{ .Path }}" 205 | main: ./cmd/validator 206 | env: 207 | - CC=x86_64-linux-gnu-gcc 208 | ldflags: 209 | - -X main.commit={{.Commit}} 210 | - -X main.date={{.Date}} 211 | - -s 212 | - -w 213 | - -extldflags "-static" 214 | tags: 215 | - musl 216 | - netgo 217 | - osusergo 218 | goos: 219 | - linux 220 | goarch: 221 | - amd64 222 | - id: casaos-app-management-validator-riscv64 223 | binary: build/sysroot/usr/bin/casaos-app-management-validator 224 | main: ./cmd/validator 225 | env: 226 | - CC=riscv64-linux-gnu-gcc 227 | ldflags: 228 | - -X main.commit={{.Commit}} 229 | - -X main.date={{.Date}} 230 | - -s 231 | - -w 232 | - -extldflags "-static" 233 | tags: 234 | - musl 235 | - netgo 236 | - osusergo 237 | goos: 238 | - linux 239 | goarch: 240 | - riscv64 241 | archives: 242 | - name_template: >- 243 | {{ .Os }}-{{- if eq .Arch "arm" }}arm-7{{- else }}{{ .Arch }}{{- end }}-{{ .ProjectName }}-v{{ .Version }} 244 | id: casaos-app-management 245 | builds: 246 | - casaos-app-management-amd64 247 | - casaos-app-management-arm64 248 | - casaos-app-management-arm-7 249 | - casaos-app-management-riscv64 250 | files: 251 | - build/**/* 252 | - name_template: >- 253 | {{ .Os }}-{{- if eq .Arch "arm" }}arm-7{{- else }}{{ .Arch }}{{- end }}-{{ .ProjectName }}-migration-tool-v{{ .Version }} 254 | id: casaos-app-management-migration-tool 255 | builds: 256 | - casaos-app-management-migration-tool-amd64 257 | - casaos-app-management-migration-tool-arm64 258 | - casaos-app-management-migration-tool-arm-7 259 | - casaos-app-management-migration-tool-riscv64 260 | files: 261 | - build/sysroot/etc/**/* 262 | - name_template: >- 263 | {{ .Os }}-{{- if eq .Arch "arm" }}arm-7{{- else }}{{ .Arch }}{{- end }}-{{ .ProjectName }}-validator-v{{ .Version }} 264 | id: casaos-app-management-validator 265 | builds: 266 | - casaos-app-management-validator-amd64 267 | - casaos-app-management-validator-riscv64 268 | files: 269 | - build/sysroot/etc/**/* 270 | checksum: 271 | name_template: "checksums.txt" 272 | snapshot: 273 | name_template: "{{ incpatch .Version }}" 274 | changelog: 275 | sort: asc 276 | filters: 277 | exclude: 278 | - "^docs:" 279 | - "^test:" 280 | release: 281 | github: 282 | owner: IceWhaleTech 283 | name: CasaOS-AppManagement 284 | draft: false 285 | prerelease: auto 286 | mode: replace 287 | name_template: "v{{ .Version }}" 288 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CasaOS-AppManagement 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/IceWhaleTech/CasaOS-AppManagement.svg)](https://pkg.go.dev/github.com/IceWhaleTech/CasaOS-AppManagement) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/IceWhaleTech/CasaOS-AppManagement)](https://goreportcard.com/report/github.com/IceWhaleTech/CasaOS-AppManagement) 5 | [![goreleaser](https://github.com/IceWhaleTech/CasaOS-AppManagement/actions/workflows/release.yml/badge.svg)](https://github.com/IceWhaleTech/CasaOS-AppManagement/actions/workflows/release.yml) 6 | [![codecov](https://codecov.io/gh/IceWhaleTech/CasaOS-AppManagement/branch/main/graph/badge.svg?token=ZCWZOFKXJT)](https://codecov.io/gh/IceWhaleTech/CasaOS-AppManagement) 7 | [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=IceWhaleTech_CasaOS-AppManagement&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=IceWhaleTech_CasaOS-AppManagement) 8 | [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=IceWhaleTech_CasaOS-AppManagement&metric=bugs)](https://sonarcloud.io/summary/new_code?id=IceWhaleTech_CasaOS-AppManagement) 9 | [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=IceWhaleTech_CasaOS-AppManagement&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=IceWhaleTech_CasaOS-AppManagement) 10 | [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=IceWhaleTech_CasaOS-AppManagement&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=IceWhaleTech_CasaOS-AppManagement) 11 | [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=IceWhaleTech_CasaOS-AppManagement&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=IceWhaleTech_CasaOS-AppManagement) 12 | 13 | App management service manages CasaOS apps lifecycle, such as installation, running, etc. 14 | -------------------------------------------------------------------------------- /api/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CasaOS | Developers 6 | 7 | 8 | 9 | 10 | 11 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /api/index_v1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CasaOS | Developers 6 | 7 | 8 | 9 | 10 | 11 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /build/scripts/migration/script.d/06-migrate-app-management.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # functions 6 | __info() { 7 | echo -e "🟩 ${1}" 8 | } 9 | 10 | __info_done() { 11 | echo -e "✅ ${1}" 12 | } 13 | 14 | __warning() { 15 | echo -e "🟨 ${1}" 16 | } 17 | 18 | __error() { 19 | echo "🟥 ${1}" 20 | exit 1 21 | } 22 | 23 | __normalize_version() { 24 | local version 25 | if [ "${1::1}" = "v" ]; then 26 | version="${1:1}" 27 | else 28 | version="${1}" 29 | fi 30 | 31 | echo "$version" 32 | } 33 | 34 | __is_version_gt() { 35 | test "$(echo "$@" | tr " " "\n" | sort -V | head -n 1)" != "$1" 36 | } 37 | 38 | __is_migration_needed() { 39 | local version1 40 | local version2 41 | 42 | version1=$(__normalize_version "${1}") 43 | version2=$(__normalize_version "${2}") 44 | 45 | if [ "${version1}" = "${version2}" ]; then 46 | return 1 47 | fi 48 | 49 | if [ "CURRENT_VERSION_NOT_FOUND" = "${version1}" ]; then 50 | return 1 51 | fi 52 | 53 | if [ "LEGACY_WITHOUT_VERSION" = "${version1}" ]; then 54 | return 0 55 | fi 56 | 57 | __is_version_gt "${version2}" "${version1}" 58 | } 59 | __get_download_domain(){ 60 | local region 61 | # Use ipconfig.io/country and https://ifconfig.io/country_code to get the country code 62 | region=$(curl --connect-timeout 2 -s ipconfig.io/country || echo "") 63 | if [ "${region}" = "" ]; then 64 | region=$(curl --connect-timeout 2 -s https://ifconfig.io/country_code || echo "") 65 | fi 66 | if [[ "${region}" = "China" ]] || [[ "${region}" = "CN" ]]; then 67 | echo "https://casaos.oss-cn-shanghai.aliyuncs.com/" 68 | else 69 | echo "https://github.com/" 70 | fi 71 | } 72 | 73 | DOWNLOAD_DOMAIN=$(__get_download_domain) 74 | BUILD_PATH=$(dirname "${BASH_SOURCE[0]}")/../../.. 75 | 76 | readonly BUILD_PATH 77 | readonly SOURCE_ROOT=${BUILD_PATH}/sysroot 78 | 79 | readonly APP_NAME="casaos-app-management" 80 | readonly APP_NAME_SHORT="app-management" 81 | readonly APP_NAME_LEGACY="casaos" 82 | 83 | # check if migration is needed 84 | readonly SOURCE_BIN_PATH=${SOURCE_ROOT}/usr/bin 85 | readonly SOURCE_BIN_FILE=${SOURCE_BIN_PATH}/${APP_NAME} 86 | 87 | readonly CURRENT_BIN_PATH=/usr/bin 88 | readonly CURRENT_BIN_PATH_LEGACY=/usr/local/bin 89 | readonly CURRENT_BIN_FILE=${CURRENT_BIN_PATH}/${APP_NAME} 90 | 91 | CURRENT_BIN_FILE_LEGACY=$(realpath -e ${CURRENT_BIN_PATH}/${APP_NAME_LEGACY} || realpath -e ${CURRENT_BIN_PATH_LEGACY}/${APP_NAME_LEGACY} || which ${APP_NAME_LEGACY} || echo CURRENT_BIN_FILE_LEGACY_NOT_FOUND) 92 | readonly CURRENT_BIN_FILE_LEGACY 93 | 94 | SOURCE_VERSION="$(${SOURCE_BIN_FILE} -v)" 95 | readonly SOURCE_VERSION 96 | 97 | CURRENT_VERSION="$(${CURRENT_BIN_FILE} -v || ${CURRENT_BIN_FILE_LEGACY} -v || (stat "${CURRENT_BIN_FILE_LEGACY}" > /dev/null && echo LEGACY_WITHOUT_VERSION) || echo CURRENT_VERSION_NOT_FOUND)" 98 | readonly CURRENT_VERSION 99 | 100 | __info_done "CURRENT_VERSION: ${CURRENT_VERSION}" 101 | __info_done "SOURCE_VERSION: ${SOURCE_VERSION}" 102 | 103 | NEED_MIGRATION=$(__is_migration_needed "${CURRENT_VERSION}" "${SOURCE_VERSION}" && echo "true" || echo "false") 104 | readonly NEED_MIGRATION 105 | 106 | if [ "${NEED_MIGRATION}" = "false" ]; then 107 | __info_done "Migration is not needed." 108 | exit 0 109 | fi 110 | 111 | ARCH="unknown" 112 | 113 | case $(uname -m) in 114 | x86_64) 115 | ARCH="amd64" 116 | ;; 117 | aarch64) 118 | ARCH="arm64" 119 | ;; 120 | armv7l) 121 | ARCH="arm-7" 122 | ;; 123 | riscv64) 124 | ARCH="riscv64" 125 | ;; 126 | *) 127 | __error "Unsupported architecture" 128 | ;; 129 | esac 130 | 131 | __info "ARCH: ${ARCH}" 132 | 133 | MIGRATION_SERVICE_DIR=${1} 134 | 135 | if [ -z "${MIGRATION_SERVICE_DIR}" ]; then 136 | MIGRATION_SERVICE_DIR=${BUILD_PATH}/scripts/migration/service.d/${APP_NAME_SHORT} 137 | fi 138 | 139 | readonly MIGRATION_LIST_FILE=${MIGRATION_SERVICE_DIR}/migration.list 140 | 141 | MIGRATION_PATH=() 142 | CURRENT_VERSION_FOUND="false" 143 | 144 | # a VERSION_PAIR looks like "v0.3.5 " 145 | # 146 | # - "v0.3.5" is the current version installed on this host 147 | # - "" is the url of the migration tool 148 | while read -r VERSION_PAIR; do 149 | if [ -z "${VERSION_PAIR}" ]; then 150 | continue 151 | fi 152 | 153 | # obtain "v0.3.5" from "v0.3.5 v0.3.6-alpha2" 154 | VER1=$(echo "${VERSION_PAIR}" | cut -d' ' -f1) 155 | 156 | # obtain "" from "v0.3.5 " 157 | URL=$(eval echo "${VERSION_PAIR}" | cut -d' ' -f2) 158 | 159 | if [ "${CURRENT_VERSION}" = "${VER1// /}" ] || [ "${CURRENT_VERSION}" = "LEGACY_WITHOUT_VERSION" ]; then 160 | CURRENT_VERSION_FOUND="true" 161 | fi 162 | 163 | if [ "${CURRENT_VERSION_FOUND}" = "true" ]; then 164 | MIGRATION_PATH+=("${URL// /}") 165 | fi 166 | done < "${MIGRATION_LIST_FILE}" 167 | 168 | if [ ${#MIGRATION_PATH[@]} -eq 0 ]; then 169 | __warning "No migration path found from ${CURRENT_VERSION} to ${SOURCE_VERSION}" 170 | exit 0 171 | fi 172 | 173 | pushd "${MIGRATION_SERVICE_DIR}" 174 | 175 | { 176 | for URL in "${MIGRATION_PATH[@]}"; do 177 | MIGRATION_TOOL_FILE=$(basename "${URL}") 178 | 179 | if [ -f "${MIGRATION_TOOL_FILE}" ]; then 180 | __info "Migration tool ${MIGRATION_TOOL_FILE} exists. Skip downloading." 181 | continue 182 | fi 183 | 184 | __info "Dowloading ${URL}..." 185 | curl -fsSL -o "${MIGRATION_TOOL_FILE}" -O "${URL}" 186 | done 187 | } || { 188 | popd 189 | __error "Failed to download migration tools" 190 | } 191 | 192 | { 193 | for URL in "${MIGRATION_PATH[@]}"; do 194 | MIGRATION_TOOL_FILE=$(basename "${URL}") 195 | __info "Extracting ${MIGRATION_TOOL_FILE}..." 196 | tar zxvf "${MIGRATION_TOOL_FILE}" || __error "Failed to extract ${MIGRATION_TOOL_FILE}" 197 | 198 | MIGRATION_TOOL_PATH=build/sysroot/usr/bin/${APP_NAME}-migration-tool 199 | __info "Running ${MIGRATION_TOOL_PATH}..." 200 | ${MIGRATION_TOOL_PATH} 201 | done 202 | } || { 203 | popd 204 | __error "Failed to extract and run migration tools" 205 | } 206 | 207 | popd 208 | -------------------------------------------------------------------------------- /build/scripts/migration/service.d/app-management/migration.list: -------------------------------------------------------------------------------- 1 | LEGACY_WITHOUT_VERSION ${DOWNLOAD_DOMAIN}IceWhaleTech/CasaOS-AppManagement/releases/download/v0.4.0-alpha7/linux-${ARCH}-casaos-app-management-migration-tool-v0.4.0-alpha7.tar.gz 2 | v0.3.5 ${DOWNLOAD_DOMAIN}IceWhaleTech/CasaOS-AppManagement/releases/download/v0.4.0-alpha7/linux-${ARCH}-casaos-app-management-migration-tool-v0.4.0-alpha7.tar.gz 3 | v0.3.5.1 ${DOWNLOAD_DOMAIN}IceWhaleTech/CasaOS-AppManagement/releases/download/v0.4.0-alpha7/linux-${ARCH}-casaos-app-management-migration-tool-v0.4.0-alpha7.tar.gz 4 | v0.3.6 ${DOWNLOAD_DOMAIN}IceWhaleTech/CasaOS-AppManagement/releases/download/v0.4.0-alpha7/linux-${ARCH}-casaos-app-management-migration-tool-v0.4.0-alpha7.tar.gz 5 | v0.3.7 ${DOWNLOAD_DOMAIN}IceWhaleTech/CasaOS-AppManagement/releases/download/v0.4.0-alpha7/linux-${ARCH}-casaos-app-management-migration-tool-v0.4.0-alpha7.tar.gz 6 | v0.3.8 ${DOWNLOAD_DOMAIN}IceWhaleTech/CasaOS-AppManagement/releases/download/v0.4.0-alpha7/linux-${ARCH}-casaos-app-management-migration-tool-v0.4.0-alpha7.tar.gz 7 | v0.4.5 ${DOWNLOAD_DOMAIN}IceWhaleTech/CasaOS-AppManagement/releases/download/v0.4.16-alpha2/linux-${ARCH}-casaos-app-management-migration-tool-v0.4.16-alpha2.tar.gz 8 | -------------------------------------------------------------------------------- /build/scripts/setup/script.d/06-setup-app-management.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | BUILD_PATH=$(dirname "${BASH_SOURCE[0]}")/../../.. 6 | 7 | readonly BUILD_PATH 8 | readonly APP_NAME_SHORT=app-management 9 | 10 | __get_setup_script_directory_by_os_release() { 11 | pushd "$(dirname "${BASH_SOURCE[0]}")/../service.d/${APP_NAME_SHORT}" >/dev/null 12 | 13 | { 14 | # shellcheck source=/dev/null 15 | { 16 | source /etc/os-release 17 | { 18 | pushd "${ID}"/"${VERSION_CODENAME}" >/dev/null 19 | } || { 20 | pushd "${ID}" >/dev/null 21 | } || { 22 | [[ -n ${ID_LIKE} ]] && for ID in ${ID_LIKE}; do 23 | pushd "${ID}" >/dev/null && break 24 | done 25 | } || { 26 | echo "Unsupported OS: ${ID} ${VERSION_CODENAME} (${ID_LIKE})" 27 | exit 1 28 | } 29 | 30 | pwd 31 | 32 | popd >/dev/null 33 | 34 | } || { 35 | echo "Unsupported OS: unknown" 36 | exit 1 37 | } 38 | 39 | } 40 | 41 | popd >/dev/null 42 | } 43 | 44 | SETUP_SCRIPT_DIRECTORY=$(__get_setup_script_directory_by_os_release) 45 | 46 | readonly SETUP_SCRIPT_DIRECTORY 47 | readonly SETUP_SCRIPT_FILENAME="setup-${APP_NAME_SHORT}.sh" 48 | readonly SETUP_SCRIPT_FILEPATH="${SETUP_SCRIPT_DIRECTORY}/${SETUP_SCRIPT_FILENAME}" 49 | 50 | { 51 | echo "🟩 Running ${SETUP_SCRIPT_FILENAME}..." 52 | $BASH "${SETUP_SCRIPT_FILEPATH}" "${BUILD_PATH}" 53 | } || { 54 | echo "🟥 ${SETUP_SCRIPT_FILENAME} failed." 55 | exit 1 56 | } 57 | 58 | echo "✅ ${SETUP_SCRIPT_FILENAME} finished." 59 | -------------------------------------------------------------------------------- /build/scripts/setup/service.d/app-management/arch/setup-app-management.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | readonly APP_NAME="casaos-app-management" 6 | readonly APP_NAME_SHORT="app-management" 7 | 8 | # copy config files 9 | readonly CONF_PATH=/etc/casaos 10 | readonly CONF_FILE=${CONF_PATH}/${APP_NAME_SHORT}.conf 11 | readonly CONF_FILE_SAMPLE=${CONF_PATH}/${APP_NAME_SHORT}.conf.sample 12 | 13 | if [ ! -f "${CONF_FILE}" ]; then \ 14 | echo "Initializing config file..." 15 | cp -v "${CONF_FILE_SAMPLE}" "${CONF_FILE}"; \ 16 | fi 17 | 18 | systemctl daemon-reload 19 | 20 | # enable service (without starting) 21 | echo "Enabling service..." 22 | systemctl enable --force --no-ask-password "${APP_NAME}.service" 23 | -------------------------------------------------------------------------------- /build/scripts/setup/service.d/app-management/debian/bullseye/setup-app-management.sh: -------------------------------------------------------------------------------- 1 | ../setup-app-management.sh -------------------------------------------------------------------------------- /build/scripts/setup/service.d/app-management/debian/setup-app-management.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | readonly APP_NAME="casaos-app-management" 6 | readonly APP_NAME_SHORT="app-management" 7 | 8 | # copy config files 9 | readonly CONF_PATH=/etc/casaos 10 | readonly CONF_FILE=${CONF_PATH}/${APP_NAME_SHORT}.conf 11 | readonly CONF_FILE_SAMPLE=${CONF_PATH}/${APP_NAME_SHORT}.conf.sample 12 | 13 | if [ ! -f "${CONF_FILE}" ]; then \ 14 | echo "Initializing config file..." 15 | cp -v "${CONF_FILE_SAMPLE}" "${CONF_FILE}"; \ 16 | fi 17 | 18 | systemctl daemon-reload 19 | 20 | # enable service (without starting) 21 | echo "Enabling service..." 22 | systemctl enable --force --no-ask-password "${APP_NAME}.service" 23 | -------------------------------------------------------------------------------- /build/scripts/setup/service.d/app-management/ubuntu/jammy/setup-app-management.sh: -------------------------------------------------------------------------------- 1 | ../setup-app-management.sh -------------------------------------------------------------------------------- /build/scripts/setup/service.d/app-management/ubuntu/setup-app-management.sh: -------------------------------------------------------------------------------- 1 | ../debian/setup-app-management.sh -------------------------------------------------------------------------------- /build/sysroot/etc/casaos/app-management.conf.sample: -------------------------------------------------------------------------------- 1 | [common] 2 | RuntimePath = /var/run/casaos 3 | 4 | [app] 5 | LogPath = /var/log/casaos/ 6 | LogSaveName = app-management 7 | LogFileExt = log 8 | AppStorePath = /var/lib/casaos/appstore 9 | AppsPath = /var/lib/casaos/apps 10 | 11 | [server] 12 | appstore = https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@gh-pages/store/main.zip 13 | appstore = https://github.com/bigbeartechworld/big-bear-casaos/archive/refs/heads/master.zip -------------------------------------------------------------------------------- /build/sysroot/etc/casaos/env: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY=sk-xxxxxx -------------------------------------------------------------------------------- /build/sysroot/usr/lib/systemd/system/casaos-app-management.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | After=casaos-message-bus.service 3 | After=docker.service 4 | Description=CasaOS App Management Service 5 | 6 | [Service] 7 | ExecStartPre=/usr/bin/casaos-app-management -v 8 | ExecStart=/usr/bin/casaos-app-management -c /etc/casaos/app-management.conf 9 | PIDFile=/var/run/casaos/app-management.pid 10 | Restart=always 11 | Type=notify 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /build/sysroot/usr/lib/systemd/system/casaos-app-management.service.buildroot: -------------------------------------------------------------------------------- 1 | [Unit] 2 | After=casaos-message-bus.service 3 | After=docker.service 4 | Description=CasaOS App Management Service 5 | 6 | [Service] 7 | ExecStartPre=/usr/bin/casaos-app-management -v 8 | ExecStart=/usr/bin/casaos-app-management -c /etc/casaos/app-management.conf --removeRuntimeIfNoNvidiaGPU true 9 | PIDFile=/run/casaos/app-management.pid 10 | Restart=always 11 | Type=notify 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /build/sysroot/usr/share/casaos/cleanup/script.d/06-cleanup-app-management.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | readonly APP_NAME_SHORT=app-management 6 | 7 | __get_setup_script_directory_by_os_release() { 8 | pushd "$(dirname "${BASH_SOURCE[0]}")/../service.d/${APP_NAME_SHORT}" &>/dev/null 9 | 10 | { 11 | # shellcheck source=/dev/null 12 | { 13 | source /etc/os-release 14 | { 15 | pushd "${ID}"/"${VERSION_CODENAME}" &>/dev/null 16 | } || { 17 | pushd "${ID}" &>/dev/null 18 | } || { 19 | [[ -n ${ID_LIKE} ]] && for ID in ${ID_LIKE}; do 20 | pushd "${ID}" >/dev/null && break 21 | done 22 | } || { 23 | echo "Unsupported OS: ${ID} ${VERSION_CODENAME} (${ID_LIKE})" 24 | exit 1 25 | } 26 | 27 | pwd 28 | 29 | popd &>/dev/null 30 | 31 | } || { 32 | echo "Unsupported OS: unknown" 33 | exit 1 34 | } 35 | 36 | } 37 | 38 | popd &>/dev/null 39 | } 40 | 41 | SETUP_SCRIPT_DIRECTORY=$(__get_setup_script_directory_by_os_release) 42 | 43 | readonly SETUP_SCRIPT_DIRECTORY 44 | readonly SETUP_SCRIPT_FILENAME="cleanup-${APP_NAME_SHORT}.sh" 45 | readonly SETUP_SCRIPT_FILEPATH="${SETUP_SCRIPT_DIRECTORY}/${SETUP_SCRIPT_FILENAME}" 46 | 47 | echo "🟩 Running ${SETUP_SCRIPT_FILENAME}..." 48 | $BASH "${SETUP_SCRIPT_FILEPATH}" "${BUILD_PATH}" 49 | -------------------------------------------------------------------------------- /build/sysroot/usr/share/casaos/cleanup/service.d/app-management/arch/cleanup-app-management.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | readonly CASA_SERVICES=( 6 | "casaos-app-management.service" 7 | ) 8 | 9 | readonly CASA_EXEC=casaos-app-management 10 | readonly CASA_CONF=/etc/casaos/app-management.conf 11 | readonly CASA_DB=/var/lib/casaos/db/app-management.db 12 | 13 | readonly aCOLOUR=( 14 | '\e[38;5;154m' # green | Lines, bullets and separators 15 | '\e[1m' # Bold white | Main descriptions 16 | '\e[90m' # Grey | Credits 17 | '\e[91m' # Red | Update notifications Alert 18 | '\e[33m' # Yellow | Emphasis 19 | ) 20 | 21 | Show() { 22 | # OK 23 | if (($1 == 0)); then 24 | echo -e "${aCOLOUR[2]}[$COLOUR_RESET${aCOLOUR[0]} OK $COLOUR_RESET${aCOLOUR[2]}]$COLOUR_RESET $2" 25 | # FAILED 26 | elif (($1 == 1)); then 27 | echo -e "${aCOLOUR[2]}[$COLOUR_RESET${aCOLOUR[3]}FAILED$COLOUR_RESET${aCOLOUR[2]}]$COLOUR_RESET $2" 28 | # INFO 29 | elif (($1 == 2)); then 30 | echo -e "${aCOLOUR[2]}[$COLOUR_RESET${aCOLOUR[0]} INFO $COLOUR_RESET${aCOLOUR[2]}]$COLOUR_RESET $2" 31 | # NOTICE 32 | elif (($1 == 3)); then 33 | echo -e "${aCOLOUR[2]}[$COLOUR_RESET${aCOLOUR[4]}NOTICE$COLOUR_RESET${aCOLOUR[2]}]$COLOUR_RESET $2" 34 | fi 35 | } 36 | 37 | Warn() { 38 | echo -e "${aCOLOUR[3]}$1$COLOUR_RESET" 39 | } 40 | 41 | trap 'onCtrlC' INT 42 | onCtrlC() { 43 | echo -e "${COLOUR_RESET}" 44 | exit 1 45 | } 46 | 47 | if [[ ! -x "$(command -v ${CASA_EXEC})" ]]; then 48 | Show 2 "${CASA_EXEC} is not detected, exit the script." 49 | exit 1 50 | fi 51 | 52 | while true; do 53 | echo -n -e " ${aCOLOUR[4]}Do you want delete app management database? Y/n :${COLOUR_RESET}" 54 | read -r input 55 | case $input in 56 | [yY][eE][sS] | [yY]) 57 | REMOVE_APP_MANAGEMENT_DATABASE=true 58 | break 59 | ;; 60 | [nN][oO] | [nN]) 61 | REMOVE_APP_MANAGEMENT_DATABASE=false 62 | break 63 | ;; 64 | *) 65 | Warn " Invalid input..." 66 | ;; 67 | esac 68 | done 69 | 70 | for SERVICE in "${CASA_SERVICES[@]}"; do 71 | Show 2 "Stopping ${SERVICE}..." 72 | systemctl disable --now "${SERVICE}" || Show 3 "Failed to disable ${SERVICE}" 73 | done 74 | 75 | rm -rvf "$(which ${CASA_EXEC})" || Show 3 "Failed to remove ${CASA_EXEC}" 76 | rm -rvf "${CASA_CONF}" || Show 3 "Failed to remove ${CASA_CONF}" 77 | 78 | if [[ ${REMOVE_APP_MANAGEMENT_DATABASE} == true ]]; then 79 | rm -rvf "${CASA_DB}" || Show 3 "Failed to remove ${CASA_DB}" 80 | fi 81 | -------------------------------------------------------------------------------- /build/sysroot/usr/share/casaos/cleanup/service.d/app-management/debian/bullseye/cleanup-app-management.sh: -------------------------------------------------------------------------------- 1 | ../cleanup-app-management.sh -------------------------------------------------------------------------------- /build/sysroot/usr/share/casaos/cleanup/service.d/app-management/debian/cleanup-app-management.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | readonly CASA_SERVICES=( 6 | "casaos-app-management.service" 7 | ) 8 | 9 | readonly CASA_EXEC=casaos-app-management 10 | readonly CASA_CONF=/etc/casaos/app-management.conf 11 | readonly CASA_DB=/var/lib/casaos/db/app-management.db 12 | 13 | readonly aCOLOUR=( 14 | '\e[38;5;154m' # green | Lines, bullets and separators 15 | '\e[1m' # Bold white | Main descriptions 16 | '\e[90m' # Grey | Credits 17 | '\e[91m' # Red | Update notifications Alert 18 | '\e[33m' # Yellow | Emphasis 19 | ) 20 | 21 | Show() { 22 | # OK 23 | if (($1 == 0)); then 24 | echo -e "${aCOLOUR[2]}[$COLOUR_RESET${aCOLOUR[0]} OK $COLOUR_RESET${aCOLOUR[2]}]$COLOUR_RESET $2" 25 | # FAILED 26 | elif (($1 == 1)); then 27 | echo -e "${aCOLOUR[2]}[$COLOUR_RESET${aCOLOUR[3]}FAILED$COLOUR_RESET${aCOLOUR[2]}]$COLOUR_RESET $2" 28 | # INFO 29 | elif (($1 == 2)); then 30 | echo -e "${aCOLOUR[2]}[$COLOUR_RESET${aCOLOUR[0]} INFO $COLOUR_RESET${aCOLOUR[2]}]$COLOUR_RESET $2" 31 | # NOTICE 32 | elif (($1 == 3)); then 33 | echo -e "${aCOLOUR[2]}[$COLOUR_RESET${aCOLOUR[4]}NOTICE$COLOUR_RESET${aCOLOUR[2]}]$COLOUR_RESET $2" 34 | fi 35 | } 36 | 37 | Warn() { 38 | echo -e "${aCOLOUR[3]}$1$COLOUR_RESET" 39 | } 40 | 41 | trap 'onCtrlC' INT 42 | onCtrlC() { 43 | echo -e "${COLOUR_RESET}" 44 | exit 1 45 | } 46 | 47 | if [[ ! -x "$(command -v ${CASA_EXEC})" ]]; then 48 | Show 2 "${CASA_EXEC} is not detected, exit the script." 49 | exit 1 50 | fi 51 | 52 | while true; do 53 | echo -n -e " ${aCOLOUR[4]}Do you want delete app management database? Y/n :${COLOUR_RESET}" 54 | read -r input 55 | case $input in 56 | [yY][eE][sS] | [yY]) 57 | REMOVE_APP_MANAGEMENT_DATABASE=true 58 | break 59 | ;; 60 | [nN][oO] | [nN]) 61 | REMOVE_APP_MANAGEMENT_DATABASE=false 62 | break 63 | ;; 64 | *) 65 | Warn " Invalid input..." 66 | ;; 67 | esac 68 | done 69 | 70 | for SERVICE in "${CASA_SERVICES[@]}"; do 71 | Show 2 "Stopping ${SERVICE}..." 72 | systemctl disable --now "${SERVICE}" || Show 3 "Failed to disable ${SERVICE}" 73 | done 74 | 75 | rm -rvf "$(which ${CASA_EXEC})" || Show 3 "Failed to remove ${CASA_EXEC}" 76 | rm -rvf "${CASA_CONF}" || Show 3 "Failed to remove ${CASA_CONF}" 77 | 78 | if [[ ${REMOVE_APP_MANAGEMENT_DATABASE} == true ]]; then 79 | rm -rvf "${CASA_DB}" || Show 3 "Failed to remove ${CASA_DB}" 80 | fi 81 | -------------------------------------------------------------------------------- /build/sysroot/usr/share/casaos/cleanup/service.d/app-management/ubuntu/cleanup-app-management.sh: -------------------------------------------------------------------------------- 1 | ../debian/cleanup-app-management.sh -------------------------------------------------------------------------------- /build/sysroot/usr/share/casaos/cleanup/service.d/app-management/ubuntu/jammy/cleanup-app-management.sh: -------------------------------------------------------------------------------- 1 | ../../debian/bullseye/cleanup-app-management.sh -------------------------------------------------------------------------------- /cmd/appfile2compose/log.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | type Logger struct { 9 | DebugMode bool 10 | 11 | _debug *log.Logger 12 | _info *log.Logger 13 | _error *log.Logger 14 | } 15 | 16 | func NewLogger() *Logger { 17 | return &Logger{ 18 | DebugMode: false, 19 | _debug: log.New(os.Stdout, "DEBUG: ", 0), 20 | _info: log.New(os.Stdout, "", 0), 21 | _error: log.New(os.Stderr, "ERROR: ", 0), 22 | } 23 | } 24 | 25 | func (l *Logger) Debug(format string, v ...interface{}) { 26 | if l.DebugMode { 27 | l._debug.Printf(format, v...) 28 | } 29 | } 30 | 31 | func (l *Logger) Info(format string, v ...interface{}) { 32 | l._info.Printf(format, v...) 33 | } 34 | 35 | func (l *Logger) Error(format string, v ...interface{}) { 36 | l._error.Printf(format, v...) 37 | } 38 | -------------------------------------------------------------------------------- /cmd/appfile2compose/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | "github.com/IceWhaleTech/CasaOS-AppManagement/model" 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | var logger = NewLogger() 12 | 13 | func main() { 14 | if len(os.Args) < 2 { 15 | os.Args = append(os.Args, "-h") 16 | } 17 | 18 | inputFlag := os.Args[1] 19 | 20 | if inputFlag == "" || inputFlag == "-h" || inputFlag == "--help" { 21 | println("Usage: appfile2compose [docker-compose.yml]") 22 | os.Exit(0) 23 | } 24 | 25 | file, err := os.Open(inputFlag) 26 | if err != nil { 27 | logger.Error("%s", err.Error()) 28 | os.Exit(1) 29 | } 30 | 31 | decoder := json.NewDecoder(file) 32 | 33 | var appFile model.CustomizationPostData 34 | if err := decoder.Decode(&appFile); err != nil { 35 | logger.Error("failed to decode appfile %s: %s", inputFlag, err.Error()) 36 | os.Exit(1) 37 | } 38 | 39 | composeApp := appFile.Compose() 40 | 41 | composeYAML, err := yaml.Marshal(composeApp) 42 | if err != nil { 43 | logger.Error("failed to marshal compose app converted from appfile %s: %s", inputFlag, err.Error()) 44 | os.Exit(1) 45 | } 46 | 47 | if len(os.Args) < 3 { 48 | println(string(composeYAML)) 49 | os.Exit(0) 50 | } 51 | 52 | outputFlag := os.Args[2] 53 | 54 | if err := os.WriteFile(outputFlag, composeYAML, 0o600); err != nil { 55 | logger.Error("failed to write %s: %s", outputFlag, err.Error()) 56 | os.Exit(1) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /cmd/message-bus-docgen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen/message_bus" 5 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 6 | "github.com/IceWhaleTech/CasaOS-Common/external" 7 | "github.com/samber/lo" 8 | ) 9 | 10 | func main() { 11 | eventTypes := lo.Map(common.EventTypes, func(item message_bus.EventType, _ int) external.EventType { 12 | return external.EventType{ 13 | Name: item.Name, 14 | SourceID: item.SourceID, 15 | PropertyTypeList: lo.Map( 16 | item.PropertyTypeList, func(item message_bus.PropertyType, _ int) external.PropertyType { 17 | return external.PropertyType{ 18 | Name: item.Name, 19 | Description: item.Description, 20 | Example: item.Example, 21 | } 22 | }, 23 | ), 24 | } 25 | }) 26 | 27 | external.PrintEventTypesAsMarkdown(common.AppManagementServiceName, common.AppManagementVersion, eventTypes) 28 | } 29 | -------------------------------------------------------------------------------- /cmd/migration-tool/log.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | type Logger struct { 9 | _debug *log.Logger 10 | _info *log.Logger 11 | _error *log.Logger 12 | DebugMode bool 13 | } 14 | 15 | func NewLogger() *Logger { 16 | return &Logger{ 17 | DebugMode: false, 18 | _debug: log.New(os.Stdout, "DEBUG: ", 0), 19 | _info: log.New(os.Stdout, "", 0), 20 | _error: log.New(os.Stderr, "ERROR: ", 0), 21 | } 22 | } 23 | 24 | func (l *Logger) Debug(format string, v ...interface{}) { 25 | if l.DebugMode { 26 | l._debug.Printf(format, v...) 27 | } 28 | } 29 | 30 | func (l *Logger) Info(format string, v ...interface{}) { 31 | l._info.Printf(format, v...) 32 | } 33 | 34 | func (l *Logger) Error(format string, v ...interface{}) { 35 | l._error.Printf(format, v...) 36 | } 37 | -------------------------------------------------------------------------------- /cmd/migration-tool/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "flag" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 10 | interfaces "github.com/IceWhaleTech/CasaOS-Common" 11 | "github.com/IceWhaleTech/CasaOS-Common/utils/systemctl" 12 | ) 13 | 14 | const ( 15 | appManagementConfigDirPath = "/etc/casaos" 16 | appManagementConfigFilePath = "/etc/casaos/app-management.conf" 17 | appManagementName = "casaos-app-management.service" 18 | appManagementNameShort = "app-management" 19 | ) 20 | 21 | //go:embedded ../../build/sysroot/etc/casaos/app-management.conf.sample 22 | //var _appManagementConfigFileSample string 23 | 24 | var ( 25 | _logger *Logger 26 | // _status *version.GlobalMigrationStatus 27 | 28 | commit = "private build" 29 | date = "private build" 30 | ) 31 | 32 | func main() { 33 | versionFlag := flag.Bool("v", false, "version") 34 | debugFlag := flag.Bool("d", true, "debug") 35 | forceFlag := flag.Bool("f", false, "force") 36 | flag.Parse() 37 | 38 | if *versionFlag { 39 | fmt.Printf("v%s\n", common.AppManagementVersion) 40 | os.Exit(0) 41 | } 42 | 43 | println("git commit:", commit) 44 | println("build date:", date) 45 | 46 | _logger = NewLogger() 47 | 48 | if os.Getuid() != 0 { 49 | _logger.Info("Root privileges are required to run this program.") 50 | os.Exit(1) 51 | } 52 | 53 | if *debugFlag { 54 | _logger.DebugMode = true 55 | } 56 | 57 | if !*forceFlag { 58 | isRunning, err := systemctl.IsServiceRunning(appManagementName) 59 | if err != nil { 60 | _logger.Error("Failed to check if %s is running", appManagementName) 61 | panic(err) 62 | } 63 | 64 | if isRunning { 65 | _logger.Info("%s is running. If migration is still needed, try with -f.", appManagementName) 66 | os.Exit(1) 67 | } 68 | } 69 | 70 | migrationTools := []interfaces.MigrationTool{ 71 | NewMigration0415AndOlder(), 72 | } 73 | 74 | var selectedMigrationTool interfaces.MigrationTool 75 | 76 | // look for the right migration tool matching current version 77 | for _, tool := range migrationTools { 78 | migrationNeeded, _ := tool.IsMigrationNeeded() 79 | 80 | if migrationNeeded { 81 | selectedMigrationTool = tool 82 | break 83 | } 84 | } 85 | 86 | if selectedMigrationTool == nil { 87 | _logger.Info("No migration to proceed.") 88 | return 89 | } 90 | 91 | if err := selectedMigrationTool.PreMigrate(); err != nil { 92 | panic(err) 93 | } 94 | 95 | if err := selectedMigrationTool.Migrate(); err != nil { 96 | panic(err) 97 | } 98 | 99 | if err := selectedMigrationTool.PostMigrate(); err != nil { 100 | _logger.Error("Migration succeeded, but post-migration failed: %s", err) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /cmd/migration-tool/migration_0412_and_older.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | interfaces "github.com/IceWhaleTech/CasaOS-Common" 10 | 11 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/config" 12 | ) 13 | 14 | type migrationTool0412AndOlder struct{} 15 | 16 | const bigBearAppStoreUrl = "https://github.com/bigbeartechworld/big-bear-casaos/archive/refs/heads/master.zip" 17 | 18 | func (u *migrationTool0412AndOlder) IsMigrationNeeded() (bool, error) { 19 | _logger.Info("Checking if migration is needed...") 20 | 21 | // read string from AppManagementConfigFilePath 22 | file, err := os.Open(config.AppManagementConfigFilePath) 23 | if err != nil { 24 | _logger.Error("failed to detect app management config file: %s", err) 25 | return false, err 26 | } 27 | defer file.Close() 28 | content, err := io.ReadAll(file) 29 | if err != nil { 30 | _logger.Error("failed to read app management config file: %s", err) 31 | return false, err 32 | } 33 | 34 | if strings.Contains(string(content), bigBearAppStoreUrl) { 35 | _logger.Info("Migration is add big bear app store. it is not needed.") 36 | return false, nil 37 | } 38 | return true, nil 39 | } 40 | 41 | func (u *migrationTool0412AndOlder) PreMigrate() error { 42 | return nil 43 | } 44 | 45 | func (u *migrationTool0412AndOlder) Migrate() error { 46 | // add big bear app store 47 | file, err := os.OpenFile(config.AppManagementConfigFilePath, os.O_RDWR, 0644) 48 | if err != nil { 49 | _logger.Error("failed to open app management config file: %s", err) 50 | return err 51 | } 52 | defer file.Close() 53 | content, err := io.ReadAll(file) 54 | if err != nil { 55 | _logger.Error("failed to read app management config file: %s", err) 56 | return err 57 | } 58 | 59 | newContent := string(content) 60 | newContent += fmt.Sprintf("\nappstore = %s", bigBearAppStoreUrl) 61 | 62 | _, err = file.WriteString(newContent) 63 | if err != nil { 64 | _logger.Error("failed to write app management config file: %s", err) 65 | return err 66 | } 67 | 68 | return nil 69 | } 70 | 71 | func (u *migrationTool0412AndOlder) PostMigrate() error { 72 | return nil 73 | } 74 | 75 | func NewMigration0412AndOlder() interfaces.MigrationTool { 76 | return &migrationTool0412AndOlder{} 77 | } 78 | -------------------------------------------------------------------------------- /cmd/migration-tool/migration_0415_and_older.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "strings" 7 | 8 | interfaces "github.com/IceWhaleTech/CasaOS-Common" 9 | 10 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/config" 11 | ) 12 | 13 | type UrlReplacement struct { 14 | OldUrl string 15 | NewUrl string 16 | } 17 | 18 | var replaceUrl = []UrlReplacement{ 19 | { 20 | OldUrl: "https://github.com/IceWhaleTech/_appstore/archive/refs/heads/main.zip", 21 | NewUrl: "https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@gh-pages/store/main.zip", 22 | }, 23 | { 24 | OldUrl: "https://casaos.oss-cn-shanghai.aliyuncs.com/IceWhaleTech/_appstore/archive/refs/heads/main.zip", 25 | NewUrl: "https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@gh-pages/store/main.zip", 26 | }, 27 | { 28 | OldUrl: "https://casaos.app/store/main.zip", 29 | NewUrl: "https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@gh-pages/store/main.zip", 30 | }, 31 | } 32 | 33 | type migrationTool0415AndOlder struct{} 34 | 35 | func (u *migrationTool0415AndOlder) IsMigrationNeeded() (bool, error) { 36 | _logger.Info("Checking if migration is needed...") 37 | 38 | // read string from AppManagementConfigFilePath 39 | file, err := os.Open(config.AppManagementConfigFilePath) 40 | if err != nil { 41 | _logger.Error("failed to detect app management config file: %s", err) 42 | return false, err 43 | } 44 | defer file.Close() 45 | content, err := io.ReadAll(file) 46 | if err != nil { 47 | _logger.Error("failed to read app management config file: %s", err) 48 | return false, err 49 | } 50 | 51 | for _, v := range replaceUrl { 52 | if strings.Contains(string(content), v.OldUrl) { 53 | _logger.Info("Migration is needed for a CasaOS with old app store link.") 54 | return true, nil 55 | } 56 | } 57 | return false, nil 58 | } 59 | 60 | func (u *migrationTool0415AndOlder) PreMigrate() error { 61 | return nil 62 | } 63 | 64 | func (u *migrationTool0415AndOlder) Migrate() error { 65 | // replace string in AppManagementConfigFilePath 66 | // replace https://github.com/IceWhaleTech/_appstore/archive/refs/heads/main.zip to https://casaos-appstore.github.io/casaos-appstore/linux-all-appstore.zip 67 | file, err := os.OpenFile(config.AppManagementConfigFilePath, os.O_RDWR, 0644) 68 | if err != nil { 69 | _logger.Error("failed to open app management config file: %s", err) 70 | return err 71 | } 72 | defer file.Close() 73 | content, err := io.ReadAll(file) 74 | if err != nil { 75 | _logger.Error("failed to read app management config file: %s", err) 76 | return err 77 | } 78 | 79 | newContent := string(content) 80 | for _, v := range replaceUrl { 81 | newContent = strings.Replace(newContent, v.OldUrl, v.NewUrl, -1) 82 | } 83 | 84 | // clear the ole content 85 | err = file.Truncate(0) 86 | if err != nil { 87 | _logger.Error("failed to truncate app management config file: %s", err) 88 | return err 89 | } 90 | 91 | _, err = file.WriteAt([]byte(newContent), 0) 92 | if err != nil { 93 | _logger.Error("failed to write app management config file: %s", err) 94 | return err 95 | } 96 | return nil 97 | } 98 | 99 | func (u *migrationTool0415AndOlder) PostMigrate() error { 100 | return nil 101 | } 102 | 103 | func NewMigration0415AndOlder() interfaces.MigrationTool { 104 | return &migrationTool0415AndOlder{} 105 | } 106 | -------------------------------------------------------------------------------- /cmd/migration-tool/migration_dummy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | interfaces "github.com/IceWhaleTech/CasaOS-Common" 5 | ) 6 | 7 | type migrationTool struct{} 8 | 9 | func (u *migrationTool) IsMigrationNeeded() (bool, error) { 10 | return false, nil 11 | } 12 | 13 | func (u *migrationTool) PreMigrate() error { 14 | return nil 15 | } 16 | 17 | func (u *migrationTool) Migrate() error { 18 | return nil 19 | } 20 | 21 | func (u *migrationTool) PostMigrate() error { 22 | return nil 23 | } 24 | 25 | func NewMigrationDummy() interfaces.MigrationTool { 26 | return &migrationTool{} 27 | } 28 | -------------------------------------------------------------------------------- /cmd/validator/log.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | type Logger struct { 9 | DebugMode bool 10 | 11 | _debug *log.Logger 12 | _info *log.Logger 13 | _error *log.Logger 14 | } 15 | 16 | func NewLogger() *Logger { 17 | return &Logger{ 18 | DebugMode: false, 19 | _debug: log.New(os.Stdout, "DEBUG: ", 0), 20 | _info: log.New(os.Stdout, "", 0), 21 | _error: log.New(os.Stderr, "ERROR: ", 0), 22 | } 23 | } 24 | 25 | func (l *Logger) Debug(format string, v ...interface{}) { 26 | if l.DebugMode { 27 | l._debug.Printf(format, v...) 28 | } 29 | } 30 | 31 | func (l *Logger) Info(format string, v ...interface{}) { 32 | l._info.Printf(format, v...) 33 | } 34 | 35 | func (l *Logger) Error(format string, v ...interface{}) { 36 | l._error.Printf(format, v...) 37 | } 38 | -------------------------------------------------------------------------------- /cmd/validator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/IceWhaleTech/CasaOS-AppManagement/cmd/validator/pkg" 8 | "github.com/IceWhaleTech/CasaOS-Common/utils/file" 9 | utils_logger "github.com/IceWhaleTech/CasaOS-Common/utils/logger" 10 | ) 11 | 12 | var logger = NewLogger() 13 | 14 | func main() { 15 | utils_logger.LogInitConsoleOnly() 16 | 17 | if len(os.Args) < 1 { 18 | os.Args = append(os.Args, "-h") 19 | } 20 | 21 | dockerComposeFilePath := os.Args[1] 22 | 23 | // check file exists 24 | if _, err := os.Stat(dockerComposeFilePath); os.IsNotExist(err) { 25 | logger.Error("docker-compose file does not exist: %s", dockerComposeFilePath) 26 | os.Exit(1) 27 | } 28 | 29 | composeFileContent := file.ReadFullFile(dockerComposeFilePath) 30 | 31 | err := pkg.VaildDockerCompose(composeFileContent) 32 | if err != nil { 33 | logger.Error("failed to parse docker-compose file %s", err.Error()) 34 | os.Exit(1) 35 | } 36 | fmt.Println("pass validate") 37 | } 38 | -------------------------------------------------------------------------------- /cmd/validator/pkg/validate.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" 5 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 6 | "github.com/IceWhaleTech/CasaOS-AppManagement/service" 7 | "github.com/compose-spec/compose-go/loader" 8 | ) 9 | 10 | func VaildDockerCompose(yaml []byte) (err error) { 11 | err = nil 12 | // recover 13 | defer func() { 14 | if r := recover(); r != nil { 15 | err = r.(error) 16 | } 17 | }() 18 | docker, err := service.NewComposeAppFromYAML(yaml, false, false) 19 | 20 | ex, ok := docker.Extensions[common.ComposeExtensionNameXCasaOS] 21 | if !ok { 22 | return service.ErrComposeExtensionNameXCasaOSNotFound 23 | } 24 | 25 | var storeInfo codegen.ComposeAppStoreInfo 26 | if err = loader.Transform(ex, &storeInfo); err != nil { 27 | return 28 | } 29 | 30 | return 31 | } 32 | -------------------------------------------------------------------------------- /common/constants.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | const ( 4 | AppManagementServiceName = "app-management" 5 | AppManagementVersion = "0.4.16" 6 | 7 | AppsDirectoryName = "Apps" 8 | 9 | ComposeAppAuthorCasaOSTeam = "CasaOS Team" 10 | 11 | ComposeExtensionNameXCasaOS = "x-casaos" 12 | ComposeExtensionPropertyNameStoreAppID = "store_app_id" 13 | ComposeExtensionPropertyNameTitle = "title" 14 | ComposeExtensionPropertyNameIsUncontrolled = "is_uncontrolled" 15 | 16 | ComposeYAMLFileName = "docker-compose.yml" 17 | 18 | ContainerLabelV1AppStoreID = "io.casaos.v1.app.store.id" 19 | 20 | DefaultCategoryFont = "grid" 21 | DefaultLanguage = "en_us" 22 | DefaultPassword = "casaos" 23 | DefaultPGID = "1000" 24 | DefaultPUID = "1000" 25 | DefaultUserName = "admin" 26 | 27 | Localhost = "127.0.0.1" 28 | MIMEApplicationYAML = "application/yaml" 29 | 30 | CategoryListFileName = "category-list.json" 31 | RecommendListFileName = "recommend-list.json" 32 | ) 33 | 34 | // the tags can add more. like "latest", "stable", "edge", "beta", "alpha" 35 | var NeedCheckDigestTags = []string{"latest"} 36 | -------------------------------------------------------------------------------- /common/context_properties.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "context" 4 | 5 | type ( 6 | keyTypeProperties int 7 | keyTypeInterpolationMap int 8 | ) 9 | 10 | const ( 11 | keyProperties keyTypeProperties = iota 12 | keyInterpolationMap keyTypeInterpolationMap = iota 13 | ) 14 | 15 | // Returns a new context with the given properties for events. 16 | func WithProperties(ctx context.Context, properties map[string]string) context.Context { 17 | return withMap(ctx, keyProperties, properties) 18 | } 19 | 20 | // Returns the properties from the context for events 21 | func PropertiesFromContext(ctx context.Context) map[string]string { 22 | return mapFromContext(ctx, keyProperties) 23 | } 24 | 25 | func withMap[T any](ctx context.Context, key T, value map[string]string) context.Context { 26 | return context.WithValue(ctx, key, value) 27 | } 28 | 29 | func mapFromContext[T any](ctx context.Context, key T) map[string]string { 30 | value := ctx.Value(key) 31 | if value == nil { 32 | return nil 33 | } 34 | 35 | if properties, ok := value.(map[string]string); ok { 36 | return properties 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /common/fixtures/sample-appfile-export.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "", 3 | "protocol": "http", 4 | "port_map": "8096", 5 | "cpu_shares": 50, 6 | "restart": "always", 7 | "label": "Emby", 8 | "position": false, 9 | "index": "/", 10 | "icon": "https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@main/Apps/Emby/icon.png", 11 | "network_model": "bridge", 12 | "image": "linuxserver/emby:4.6.7", 13 | "description": "Bringing all of your home videos, music, and photos together into one place has never been easier. Your personal Emby Server automatically converts and streams your media on-the-fly to play on any device.", 14 | "origin": "official", 15 | "ports": [ 16 | { 17 | "container": "8096", 18 | "host": "8096", 19 | "protocol": "tcp", 20 | "desc": "", 21 | "type": 0 22 | }, 23 | { 24 | "container": "8920", 25 | "host": "8920", 26 | "protocol": "tcp", 27 | "desc": "", 28 | "type": 0 29 | } 30 | ], 31 | "volumes": [ 32 | { 33 | "container": "/config", 34 | "host": "/DATA/AppData/Emby/config", 35 | "type": 0, 36 | "desc": "" 37 | }, 38 | { 39 | "container": "/data/tvshows", 40 | "host": "/DATA/Media/TV Shows", 41 | "type": 0, 42 | "desc": "" 43 | }, 44 | { 45 | "container": "/data/movies", 46 | "host": "/DATA/Media/Movies", 47 | "type": 0, 48 | "desc": "" 49 | } 50 | ], 51 | "envs": [ 52 | { 53 | "container": "PUID", 54 | "host": "1000", 55 | "desc": "", 56 | "type": 0 57 | }, 58 | { 59 | "container": "PGID", 60 | "host": "1000", 61 | "desc": "", 62 | "type": 0 63 | }, 64 | { 65 | "container": "TZ", 66 | "host": "Asia/Singapore", 67 | "desc": "", 68 | "type": 0 69 | } 70 | ], 71 | "devices": [], 72 | "cap_add": [], 73 | "cmd": [], 74 | "privileged": false, 75 | "host_name": "Emby", 76 | "container_name": "Emby", 77 | "appstore_id": 83, 78 | "custom_id": "7d3dc208-9733-4e58-9e49-af02664912df", 79 | "enable_upnp": false, 80 | "version": "1.0" 81 | } -------------------------------------------------------------------------------- /common/fixtures/sample-category-list.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Analytics", 4 | "icon": "chart-areaspline", 5 | "description": "Analysis Apps" 6 | }, 7 | { 8 | "name": "Backup", 9 | "icon": "backup-restore", 10 | "description": "File and Data Backup Apps" 11 | }, 12 | { 13 | "name": "Blog", 14 | "icon": "post-outline", 15 | "description": "Blog Writing and Distribution Apps" 16 | }, 17 | { 18 | "name": "Chat", 19 | "icon": "chat-processing-outline", 20 | "description": "Chat Apps" 21 | }, 22 | { 23 | "name": "Cloud", 24 | "icon": "cloud-outline", 25 | "description": "Public cloud alternatives or enhanced Apps" 26 | }, 27 | { 28 | "name": "Developer", 29 | "icon": "code-greater-than-or-equal", 30 | "description": "Developer Apps" 31 | }, 32 | { 33 | "name": "CRM", 34 | "icon": "account-box-outline", 35 | "description": "CRM Apps" 36 | }, 37 | { 38 | "name": "Documents", 39 | "icon": "file-document-outline", 40 | "description": "Document editing Apps" 41 | }, 42 | { 43 | "name": "Email", 44 | "icon": "email-outline", 45 | "description": "Email Apps" 46 | }, 47 | { 48 | "name": "File Sync", 49 | "icon": "file-sync-outline", 50 | "description": "File Sync Apps" 51 | }, 52 | { 53 | "name": "Finance", 54 | "icon": "currency-usd", 55 | "description": "Finance Apps" 56 | }, 57 | { 58 | "name": "Forum", 59 | "icon": "forum-outline", 60 | "description": "Forum Apps" 61 | }, 62 | { 63 | "name": "Gallery", 64 | "icon": "image", 65 | "description": "Gallery Apps" 66 | }, 67 | { 68 | "name": "Games", 69 | "icon": "google-controller", 70 | "description": "Games Apps" 71 | }, 72 | { 73 | "name": "Learning", 74 | "icon": "school-outline", 75 | "description": "Learning Apps" 76 | }, 77 | { 78 | "name": "Media", 79 | "icon": "play-circle-outline", 80 | "description": "Media Apps" 81 | }, 82 | { 83 | "name": "Notes", 84 | "icon": "note-edit-outline", 85 | "description": "Notes Apps" 86 | }, 87 | { 88 | "name": "Project Management", 89 | "icon": "chart-gantt", 90 | "description": "Project Management Apps" 91 | }, 92 | { 93 | "name": "VPN", 94 | "icon": "vpn", 95 | "description": "VPN Apps" 96 | }, 97 | { 98 | "name": "WEB", 99 | "icon": "web", 100 | "description": "WEB Apps" 101 | }, 102 | { 103 | "name": "WiKi", 104 | "icon": "book-open-page-variant-outline", 105 | "description": "WiKi Apps" 106 | }, 107 | { 108 | "name": "Dapps", 109 | "icon": "vector-link", 110 | "description": "Decentralized Apps" 111 | }, 112 | { 113 | "name": "Downloader", 114 | "icon": "tray-arrow-down", 115 | "description": "Downloader Apps" 116 | }, 117 | { 118 | "name": "Utilities", 119 | "icon": "toolbox-outline", 120 | "description": "Utilities Apps" 121 | }, 122 | { 123 | "name": "Home Automation", 124 | "icon": "home-automation", 125 | "description": "Home Automation Apps" 126 | }, 127 | { 128 | "name": "Network", 129 | "icon": "lan", 130 | "description": "Network Apps" 131 | } 132 | ] -------------------------------------------------------------------------------- /common/fixtures/sample-vanilla.docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | syncthing: 3 | environment: 4 | PGID: $PGID 5 | PUID: $PUID 6 | TZ: $TZ 7 | image: linuxserver/syncthing:1.23.1 8 | deploy: 9 | resources: 10 | reservations: 11 | memory: "256M" 12 | network_mode: bridge 13 | ports: 14 | - target: 8384 15 | published: "8384" 16 | protocol: tcp 17 | - target: 22000 18 | published: "22000" 19 | protocol: tcp 20 | - target: 22000 21 | published: "22000" 22 | protocol: udp 23 | - target: 21027 24 | published: "21027" 25 | protocol: udp 26 | restart: unless-stopped 27 | volumes: 28 | - type: bind 29 | source: /DATA/AppData/$AppID/config 30 | target: /config 31 | - type: bind 32 | source: /DATA 33 | target: /DATA 34 | -------------------------------------------------------------------------------- /common/fixtures/sample.docker-compose.yaml: -------------------------------------------------------------------------------- 1 | name: syncthing 2 | services: 3 | syncthing: 4 | environment: 5 | PGID: $PGID 6 | PUID: $PUID 7 | TZ: $TZ 8 | image: linuxserver/syncthing:1.23.1 9 | deploy: 10 | resources: 11 | reservations: 12 | memory: "256M" 13 | network_mode: bridge 14 | ports: 15 | - target: 8384 16 | published: "8384" 17 | protocol: tcp 18 | - target: 22000 19 | published: "22000" 20 | protocol: tcp 21 | - target: 22000 22 | published: "22000" 23 | protocol: udp 24 | - target: 21027 25 | published: "21027" 26 | protocol: udp 27 | restart: unless-stopped 28 | volumes: 29 | - type: bind 30 | source: /DATA/AppData/$AppID/config 31 | target: /config 32 | - type: bind 33 | source: /DATA 34 | target: /DATA 35 | x-casaos: 36 | envs: 37 | - container: TZ 38 | description: 39 | en_us: TimeZone 40 | - container: PUID 41 | description: 42 | en_us: Run Syncthing as specified uid. 43 | - container: PGID 44 | description: 45 | en_us: Run Syncthing as specified gid. 46 | ports: 47 | - container: "8384" 48 | description: 49 | en_us: WebUI HTTP Port 50 | 51 | - container: "22000" 52 | description: 53 | en_us: Syncthing listening Port (TCP) 54 | 55 | - container: "22000" 56 | description: 57 | en_us: Syncthing listening Port (UDP) 58 | protocol: udp 59 | - container: "21027" 60 | description: 61 | en_us: Syncthing protocol discovery Port (TCP) 62 | protocol: udp 63 | 64 | volumes: 65 | - container: /config 66 | description: 67 | en_us: Syncthing config directory. 68 | - container: /DATA 69 | description: 70 | en_us: Syncthing Accessible Directory. 71 | 72 | x-casaos: 73 | author: CasaOS Team 74 | category: Backup 75 | description: 76 | en_us: Syncthing is a continuous file synchronization program. It synchronizes files between two or more computers in real time, safely protected from prying eyes. Your data is your data alone and you deserve to choose where it is stored, whether it is shared with some third party, and how it's transmitted over the internet. 77 | developer: Syncthing 78 | icon: https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@main/Apps/Syncthing/icon.png 79 | tagline: 80 | en_us: Free, secure, and distributed file synchronisation tool. 81 | thumbnail: https://cdn.jsdelivr.net/gh/IceWhaleTech/CasaOS-AppStore@main/Apps/Jellyfin/thumbnail.jpg 82 | tips: {} 83 | title: 84 | en_us: Syncthing 85 | 86 | architectures: 87 | - amd64 88 | - arm 89 | - arm64 90 | main: syncthing 91 | index: / 92 | port_map: "8384" 93 | -------------------------------------------------------------------------------- /common/test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import _ "embed" 4 | 5 | //go:embed fixtures/sample-appfile-export.json 6 | var SampleLegacyAppfileExportJSON string 7 | 8 | //go:embed fixtures/sample-category-list.json 9 | var SampleCategoryListJSON string 10 | 11 | //go:embed fixtures/sample.docker-compose.yaml 12 | var SampleComposeAppYAML string 13 | 14 | //go:embed fixtures/sample-vanilla.docker-compose.yaml 15 | var SampleVanillaComposeAppYAML string 16 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | //go:generate bash -c "mkdir -p codegen && go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen@v1.12.4 -generate types,server,spec -package codegen api/app_management/openapi.yaml > codegen/app_management_api.go" 2 | //go:generate bash -c "mkdir -p codegen/message_bus && go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen@v1.12.4 -generate types,client -package message_bus https://raw.githubusercontent.com/IceWhaleTech/CasaOS-MessageBus/main/api/message_bus/openapi.yaml > codegen/message_bus/api.go" 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | _ "embed" 9 | "flag" 10 | "fmt" 11 | "net" 12 | "net/http" 13 | "os" 14 | "path/filepath" 15 | "time" 16 | 17 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 18 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/config" 19 | "github.com/IceWhaleTech/CasaOS-AppManagement/route" 20 | "github.com/IceWhaleTech/CasaOS-AppManagement/service" 21 | "github.com/IceWhaleTech/CasaOS-Common/model" 22 | "github.com/IceWhaleTech/CasaOS-Common/utils/file" 23 | "github.com/IceWhaleTech/CasaOS-Common/utils/logger" 24 | "github.com/coreos/go-systemd/daemon" 25 | "github.com/robfig/cron/v3" 26 | "go.uber.org/zap" 27 | 28 | util_http "github.com/IceWhaleTech/CasaOS-Common/utils/http" 29 | ) 30 | 31 | var ( 32 | commit = "private build" 33 | date = "private build" 34 | 35 | //go:embed api/index.html 36 | _docHTML string 37 | 38 | //go:embed api/index_v1.html 39 | _docHTMLV1 string 40 | 41 | //go:embed api/app_management/openapi.yaml 42 | _docYAML string 43 | 44 | //go:embed api/app_management/openapi_v1.yaml 45 | _docYAMLV1 string 46 | 47 | //go:embed build/sysroot/etc/casaos/app-management.conf.sample 48 | _confSample string 49 | ) 50 | 51 | func main() { 52 | ctx, cancel := context.WithCancel(context.Background()) 53 | defer cancel() 54 | 55 | // parse arguments and intialize 56 | { 57 | configFlag := flag.String("c", "", "config file path") 58 | versionFlag := flag.Bool("v", false, "version") 59 | removeRuntimeIfNoNvidiaGPUFlag := flag.Bool("removeRuntimeIfNoNvidiaGPU", false, "remove runtime with nvidia gpu") 60 | 61 | flag.Parse() 62 | 63 | if *versionFlag { 64 | fmt.Printf("v%s\n", common.AppManagementVersion) 65 | os.Exit(0) 66 | } 67 | 68 | println("git commit:", commit) 69 | println("build date:", date) 70 | 71 | config.InitSetup(*configFlag, _confSample) 72 | config.InitGlobal(*configFlag) 73 | 74 | logger.LogInit(config.AppInfo.LogPath, config.AppInfo.LogSaveName, config.AppInfo.LogFileExt) 75 | 76 | service.MyService = service.NewService(config.CommonInfo.RuntimePath) 77 | 78 | config.RemoveRuntimeIfNoNvidiaGPUFlag = *removeRuntimeIfNoNvidiaGPUFlag 79 | } 80 | 81 | // setup cron 82 | { 83 | crontab := cron.New(cron.WithSeconds()) 84 | 85 | // schedule async v2job to get v2 appstore list 86 | go func() { 87 | // run once at startup 88 | if err := service.MyService.AppStoreManagement().UpdateCatalog(); err != nil { 89 | logger.Error("error when updating AppStore catalog at startup", zap.Error(err)) 90 | } 91 | }() 92 | 93 | if _, err := crontab.AddFunc("@every 10m", func() { 94 | if err := service.MyService.AppStoreManagement().UpdateCatalog(); err != nil { 95 | logger.Error("error when updating AppStore catalog", zap.Error(err)) 96 | } 97 | }); err != nil { 98 | panic(err) 99 | } 100 | 101 | crontab.Start() 102 | defer crontab.Stop() 103 | 104 | } 105 | 106 | // register at message bus 107 | { 108 | response, err := service.MyService.MessageBus().RegisterEventTypesWithResponse(ctx, common.EventTypes) 109 | if err != nil { 110 | logger.Error("error when trying to register one or more event types - some event type will not be discoverable", zap.Error(err)) 111 | } 112 | 113 | if response != nil && response.StatusCode() != http.StatusOK { 114 | logger.Error("error when trying to register one or more event types - some event type will not be discoverable", zap.String("status", response.Status()), zap.String("body", string(response.Body))) 115 | } 116 | } 117 | 118 | // setup listener 119 | listener, err := net.Listen("tcp", net.JoinHostPort(common.Localhost, "0")) 120 | if err != nil { 121 | panic(err) 122 | } 123 | 124 | urlFilePath := filepath.Join(config.CommonInfo.RuntimePath, "app-management.url") 125 | if err := file.CreateFileAndWriteContent(urlFilePath, "http://"+listener.Addr().String()); err != nil { 126 | logger.Error("error when creating address file", zap.Error(err), 127 | zap.Any("address", listener.Addr().String()), 128 | zap.Any("filepath", urlFilePath), 129 | ) 130 | } 131 | 132 | // initialize routers and register at gateway 133 | { 134 | apiPaths := []string{ 135 | "/v1/apps", 136 | "/v1/container", 137 | "/v1/app-categories", 138 | route.V1DocPath, 139 | route.V2APIPath, 140 | route.V2DocPath, 141 | } 142 | 143 | for _, apiPath := range apiPaths { 144 | if err := service.MyService.Gateway().CreateRoute(&model.Route{ 145 | Path: apiPath, 146 | Target: "http://" + listener.Addr().String(), 147 | }); err != nil { 148 | panic(err) 149 | } 150 | } 151 | } 152 | 153 | v1Router := route.InitV1Router() 154 | v2Router := route.InitV2Router() 155 | v1DocRouter := route.InitV1DocRouter(_docHTMLV1, _docYAMLV1) 156 | v2DocRouter := route.InitV2DocRouter(_docHTML, _docYAML) 157 | 158 | mux := &util_http.HandlerMultiplexer{ 159 | HandlerMap: map[string]http.Handler{ 160 | "v1": v1Router, 161 | "v2": v2Router, 162 | "v1doc": v1DocRouter, 163 | "doc": v2DocRouter, 164 | }, 165 | } 166 | 167 | // notify systemd that we are ready 168 | { 169 | if supported, err := daemon.SdNotify(false, daemon.SdNotifyReady); err != nil { 170 | logger.Error("Failed to notify systemd that casaos main service is ready", zap.Any("error", err)) 171 | } else if supported { 172 | logger.Info("Notified systemd that casaos main service is ready") 173 | } else { 174 | logger.Info("This process is not running as a systemd service.") 175 | } 176 | 177 | logger.Info("App management service is listening...", zap.Any("address", listener.Addr().String())) 178 | } 179 | 180 | s := &http.Server{ 181 | Handler: mux, 182 | ReadHeaderTimeout: 5 * time.Second, // fix G112: Potential slowloris attack (see https://github.com/securego/gosec) 183 | } 184 | 185 | err = s.Serve(listener) // not using http.serve() to fix G114: Use of net/http serve function that has no support for setting timeouts (see https://github.com/securego/gosec) 186 | if err != nil { 187 | panic(err) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /model/app.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type ServerAppListCollection struct { 8 | List []ServerAppList `json:"list"` 9 | Recommend []ServerAppList `json:"recommend"` 10 | Community []ServerAppList `json:"community"` 11 | } 12 | 13 | type StateEnum int 14 | 15 | const ( 16 | StateEnumNotInstalled StateEnum = iota 17 | StateEnumInstalled 18 | ) 19 | 20 | // @tiger - 对于用于出参的数据结构,静态信息(例如 title)和 21 | // 22 | // 动态信息(例如 state、query_count)应该划分到不同的数据结构中 23 | // 24 | // 这样的好处是 25 | // 1 - 多次获取动态信息时可以减少出参复杂度,因为静态信息只获取一次就好 26 | // 2 - 在未来的迭代中,可以降低维护成本(所有字段都展开放在一个层级维护成本略高) 27 | // 28 | // 另外,一些针对性字段,例如 Docker 相关的,可以用 map 来保存。 29 | // 这样在未来增加多态 App,例如 Snap,不需要维护多个结构,或者一个结构保存不必要的字段 30 | type ServerAppList struct { 31 | ID uint `gorm:"column:id;primary_key" json:"id"` 32 | Title string `json:"title"` 33 | Description string `json:"description"` 34 | Tagline string `json:"tagline"` 35 | Tags Strings `gorm:"type:json" json:"tags"` 36 | Icon string `json:"icon"` 37 | ScreenshotLink Strings `gorm:"type:json" json:"screenshot_link"` 38 | Category string `json:"category"` 39 | CategoryID int `json:"category_id"` 40 | CategoryFont string `json:"category_font"` 41 | PortMap string `json:"port_map"` 42 | ImageVersion string `json:"image_version"` 43 | Tip string `json:"tip"` 44 | Envs EnvArray `json:"envs"` 45 | Ports PortArray `json:"ports"` 46 | Volumes PathArray `json:"volumes"` 47 | Devices PathArray `json:"devices"` 48 | NetworkModel string `json:"network_model"` 49 | Image string `json:"image"` 50 | Index string `json:"index"` 51 | CreatedAt time.Time `json:"created_at"` 52 | UpdatedAt time.Time `json:"updated_at"` 53 | State StateEnum `json:"state"` 54 | Author string `json:"author"` 55 | MinMemory int `json:"min_memory"` 56 | MinDisk int `json:"min_disk"` 57 | Thumbnail string `json:"thumbnail"` 58 | Healthy string `json:"healthy"` 59 | Plugins Strings `json:"plugins"` 60 | Origin string `json:"origin"` 61 | Type int `json:"type"` 62 | QueryCount int `json:"query_count"` 63 | Developer string `json:"developer"` 64 | HostName string `json:"host_name"` 65 | Privileged bool `json:"privileged"` 66 | CapAdd Strings `json:"cap_add"` 67 | Cmd Strings `json:"cmd"` 68 | Architectures Strings `json:"architectures"` 69 | LatestDigest Strings `json:"latest_digests"` 70 | } 71 | 72 | type MyAppList struct { 73 | ID string `json:"id"` 74 | Name string `json:"name"` 75 | Icon string `json:"icon"` 76 | State string `json:"state"` 77 | CustomID string `gorm:"column:custom_id;primary_key" json:"custom_id"` 78 | Index string `json:"index"` 79 | Port string `json:"port"` 80 | Slogan string `json:"slogan"` 81 | Type string `json:"type"` 82 | Image string `json:"image"` 83 | Volumes string `json:"volumes"` 84 | Latest bool `json:"latest"` 85 | Host string `json:"host"` 86 | Protocol string `json:"protocol"` 87 | Created int64 `json:"created"` 88 | AppStoreID uint `json:"appstore_id"` 89 | IsUncontrolled bool `json:"is_uncontrolled"` 90 | } 91 | 92 | type Ports struct { 93 | ContainerPort uint `json:"container_port"` 94 | CommendPort int `json:"commend_port"` 95 | Desc string `json:"desc"` 96 | Type int `json:"type"` // 1:必选 2:可选 3:默认值不必显示 4:系统处理 5:container内容也可编辑 97 | } 98 | 99 | type Volume struct { 100 | ContainerPath string `json:"container_path"` 101 | Path string `json:"path"` 102 | Desc string `json:"desc"` 103 | Type int `json:"type"` // 1:必选 2:可选 3:默认值不必显示 4:系统处理 5:container内容也可编辑 104 | } 105 | 106 | type Envs struct { 107 | Name string `json:"name"` 108 | Value string `json:"value"` 109 | Desc string `json:"desc"` 110 | Type int `json:"type"` // 1:必选 2:可选 3:默认值不必显示 4:系统处理 5:container内容也可编辑 111 | } 112 | 113 | type Devices struct { 114 | ContainerPath string `json:"container_path"` 115 | Path string `json:"path"` 116 | Desc string `json:"desc"` 117 | Type int `json:"type"` // 1:必选 2:可选 3:默认值不必显示 4:系统处理 5:container内容也可编辑 118 | } 119 | 120 | type Strings []string 121 | 122 | type MapStrings []map[string]string 123 | -------------------------------------------------------------------------------- /model/category.go: -------------------------------------------------------------------------------- 1 | /*@Author: link a624669980@163.com 2 | *@Date: 2022-05-16 17:37:08 3 | *@LastEditors: LinkLeong 4 | *@LastEditTime: 2022-07-13 10:46:38 5 | *@FilePath: /CasaOS/model/category.go 6 | *@Description: 7 | */ 8 | package model 9 | 10 | type ServerCategoryList struct { 11 | Item []Category `json:"item"` 12 | } 13 | type Category struct { 14 | ID uint `gorm:"column:id;primary_key" json:"id"` 15 | //CreatedAt time.Time `json:"created_at"` 16 | // 17 | //UpdatedAt time.Time `json:"updated_at"` 18 | Font string `json:"font"` // @tiger - 如果这个和前端有关,应该不属于后端的出参范围,而是前端去界定 19 | Name string `json:"name"` 20 | Count uint `json:"count"` // @tiger - count 属于动态信息,应该单独放在一个出参结构中(原因见另外一个关于 静态/动态 出参的注释) 21 | } 22 | -------------------------------------------------------------------------------- /model/docker.go: -------------------------------------------------------------------------------- 1 | /*@Author: LinkLeong link@icewhale.com 2 | *@Date: 2021-12-08 18:10:25 3 | *@LastEditors: LinkLeong 4 | *@LastEditTime: 2022-07-13 10:49:16 5 | *@FilePath: /CasaOS/model/docker.go 6 | *@Description: 7 | *@Website: https://www.casaos.io 8 | *Copyright (c) 2022 by icewhale, All Rights Reserved. 9 | */ 10 | package model 11 | 12 | type DockerStatsModel struct { 13 | Icon string `json:"icon"` 14 | Title string `json:"title"` 15 | Data interface{} `json:"data"` 16 | Previous interface{} `json:"previous"` 17 | } 18 | 19 | // reference - https://docs.docker.com/engine/reference/commandline/dockerd/#daemon-configuration-file 20 | type DockerDaemonConfigurationModel struct { 21 | // e.g. `/var/lib/docker` 22 | Root string `json:"data-root,omitempty"` 23 | } 24 | -------------------------------------------------------------------------------- /model/manifest.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type TCPPorts struct { 4 | Desc string `json:"desc"` 5 | ContainerPort int `json:"container_port"` 6 | } 7 | type UDPPorts struct { 8 | Desc string `json:"desc"` 9 | ContainerPort int `json:"container_port"` 10 | } 11 | 12 | /*******************使用gorm支持json************************************/ 13 | 14 | type PortMap struct { 15 | ContainerPort string `json:"container"` 16 | CommendPort string `json:"host"` 17 | Protocol string `json:"protocol"` 18 | Desc string `json:"desc"` 19 | Type int `json:"type"` 20 | } 21 | 22 | type PortArray []PortMap 23 | 24 | /************************************************************************/ 25 | 26 | /*******************使用gorm支持json************************************/ 27 | 28 | type Env struct { 29 | Name string `json:"container"` 30 | Value string `json:"host"` 31 | Desc string `json:"desc"` 32 | Type int `json:"type"` 33 | } 34 | 35 | type EnvArray []Env 36 | 37 | /************************************************************************/ 38 | 39 | /*******************使用gorm支持json************************************/ 40 | 41 | type PathMap struct { 42 | ContainerPath string `json:"container"` 43 | Path string `json:"host"` 44 | Type int `json:"type"` 45 | Desc string `json:"desc"` 46 | } 47 | 48 | type PathArray []PathMap 49 | 50 | /************************************************************************/ 51 | 52 | //type PostData struct { 53 | // Envs EnvArrey `json:"envs,omitempty"` 54 | // Udp PortArrey `json:"udp_ports"` 55 | // Tcp PortArrey `json:"tcp_ports"` 56 | // Volumes PathArrey `json:"volumes"` 57 | // Devices PathArrey `json:"devices"` 58 | // Port string `json:"port,omitempty"` 59 | // PortMap string `json:"port_map"` 60 | // CpuShares int64 `json:"cpu_shares,omitempty"` 61 | // Memory int64 `json:"memory,omitempty"` 62 | // Restart string `json:"restart,omitempty"` 63 | // EnableUPNP bool `json:"enable_upnp"` 64 | // Label string `json:"label"` 65 | // Position bool `json:"position"` 66 | //} 67 | 68 | type CustomizationPostData struct { 69 | ContainerName string `json:"container_name"` 70 | CustomID string `json:"custom_id"` 71 | Origin string `json:"origin"` 72 | NetworkModel string `json:"network_model"` 73 | Index string `json:"index"` 74 | Icon string `json:"icon"` 75 | Image string `json:"image"` 76 | Envs EnvArray `json:"envs"` 77 | Ports PortArray `json:"ports"` 78 | Volumes PathArray `json:"volumes"` 79 | Devices PathArray `json:"devices"` 80 | // Port string `json:"port,omitempty"` 81 | PortMap string `json:"port_map"` 82 | CPUShares int64 `json:"cpu_shares"` 83 | Memory int64 `json:"memory"` 84 | Restart string `json:"restart"` 85 | EnableUPNP bool `json:"enable_upnp"` 86 | Label string `json:"label"` 87 | Description string `json:"description"` 88 | Position bool `json:"position"` 89 | HostName string `json:"host_name"` 90 | Privileged bool `json:"privileged"` 91 | CapAdd []string `json:"cap_add"` 92 | Cmd []string `json:"cmd"` 93 | Protocol string `json:"protocol"` 94 | Host string `json:"host"` 95 | AppStoreID uint `json:"appstore_id"` 96 | } 97 | -------------------------------------------------------------------------------- /model/manifest_adapter.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" 10 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 11 | "github.com/compose-spec/compose-go/types" 12 | "github.com/samber/lo" 13 | ) 14 | 15 | func (p *PortMap) ServicePortConfig() (types.ServicePortConfig, error) { 16 | target, err := strconv.Atoi(p.ContainerPort) 17 | if err != nil { 18 | return types.ServicePortConfig{}, err 19 | } 20 | 21 | return types.ServicePortConfig{ 22 | Target: uint32(target), 23 | Published: p.CommendPort, 24 | Protocol: p.Protocol, 25 | }, nil 26 | } 27 | 28 | func (p *PortArray) ServicePortConfigList() []types.ServicePortConfig { 29 | ports := []types.ServicePortConfig{} 30 | 31 | for _, port := range *p { 32 | servicePortConfig, err := port.ServicePortConfig() 33 | if err != nil { 34 | continue 35 | } 36 | 37 | ports = append(ports, servicePortConfig) 38 | } 39 | return ports 40 | } 41 | 42 | func (p *PortArray) PortStoreInfoList() []codegen.PortStoreInfo { 43 | return lo.Map(*p, func(p PortMap, i int) codegen.PortStoreInfo { 44 | return codegen.PortStoreInfo{ 45 | Container: p.ContainerPort, 46 | Description: map[string]string{common.DefaultLanguage: p.Desc}, 47 | } 48 | }) 49 | } 50 | 51 | func (p *PathArray) ServiceVolumeConfigList() []types.ServiceVolumeConfig { 52 | volumes := []types.ServiceVolumeConfig{} 53 | 54 | for _, path := range *p { 55 | 56 | volumeType := "volume" 57 | if strings.Contains(path.Path, "/") { 58 | volumeType = "bind" 59 | } 60 | 61 | volumes = append(volumes, types.ServiceVolumeConfig{ 62 | Type: volumeType, 63 | Source: path.Path, 64 | Target: path.ContainerPath, 65 | }) 66 | } 67 | return volumes 68 | } 69 | 70 | func (p *PathArray) ToSlice() []string { 71 | return lo.Map(*p, func(p PathMap, i int) string { 72 | return fmt.Sprintf("%s:%s", p.Path, p.ContainerPath) 73 | }) 74 | } 75 | 76 | func (p *PathArray) DeviceStoreInfoList() []codegen.DeviceStoreInfo { 77 | return lo.Map(*p, func(p PathMap, i int) codegen.DeviceStoreInfo { 78 | return codegen.DeviceStoreInfo{ 79 | Container: &p.ContainerPath, 80 | Description: &map[string]string{common.DefaultLanguage: p.Desc}, 81 | } 82 | }) 83 | } 84 | 85 | func (p *PathArray) VolumeStoreInfoList() []codegen.VolumeStoreInfo { 86 | return lo.Map(*p, func(p PathMap, i int) codegen.VolumeStoreInfo { 87 | return codegen.VolumeStoreInfo{ 88 | Container: p.ContainerPath, 89 | Description: map[string]string{common.DefaultLanguage: p.Desc}, 90 | } 91 | }) 92 | } 93 | 94 | func (ea *EnvArray) ToMappingWithEquals() types.MappingWithEquals { 95 | return lo.SliceToMap(*ea, func(e Env) (string, *string) { 96 | return e.Name, &e.Value 97 | }) 98 | } 99 | 100 | func (ea *EnvArray) EnvStoreInfoList() []codegen.EnvStoreInfo { 101 | return lo.Map(*ea, func(e Env, i int) codegen.EnvStoreInfo { 102 | return codegen.EnvStoreInfo{ 103 | Container: e.Name, 104 | Description: map[string]string{common.DefaultLanguage: e.Desc}, 105 | } 106 | }) 107 | } 108 | 109 | func (c *CustomizationPostData) AppStoreInfo() codegen.AppStoreInfo { 110 | return codegen.AppStoreInfo{ 111 | Devices: c.Devices.DeviceStoreInfoList(), 112 | Envs: c.Envs.EnvStoreInfoList(), 113 | Ports: c.Ports.PortStoreInfoList(), 114 | Volumes: c.Volumes.VolumeStoreInfoList(), 115 | } 116 | } 117 | 118 | func (c *CustomizationPostData) ComposeAppStoreInfo() codegen.ComposeAppStoreInfo { 119 | name := strings.ToLower(c.ContainerName) 120 | 121 | message := "This is a compose app converted from a legacy app (CasaOS v0.4.3 or earlier)" 122 | 123 | return codegen.ComposeAppStoreInfo{ 124 | Architectures: &[]string{runtime.GOARCH}, 125 | Author: "CasaOS User", 126 | Category: "unknown", 127 | Description: map[string]string{common.DefaultLanguage: c.Description}, 128 | Developer: "unknown", 129 | Icon: c.Icon, 130 | Index: c.Index, 131 | Main: &name, 132 | PortMap: c.PortMap, 133 | Scheme: (*codegen.Scheme)(&c.Protocol), 134 | Tagline: map[string]string{common.DefaultLanguage: message}, 135 | Tips: codegen.TipsStoreInfo{Custom: &message}, 136 | Title: map[string]string{common.DefaultLanguage: c.Label}, 137 | } 138 | } 139 | 140 | func (c *CustomizationPostData) Services() types.Services { 141 | return types.Services{ 142 | { 143 | CapAdd: c.CapAdd, 144 | Command: emtpySliceThenNil(c.Cmd), 145 | CPUShares: c.CPUShares, 146 | Devices: c.Devices.ToSlice(), 147 | Environment: c.Envs.ToMappingWithEquals(), 148 | Image: c.Image, 149 | Name: strings.ToLower(c.ContainerName), 150 | NetworkMode: c.NetworkModel, 151 | Ports: c.Ports.ServicePortConfigList(), 152 | Privileged: c.Privileged, 153 | Restart: c.Restart, 154 | Volumes: c.Volumes.ServiceVolumeConfigList(), 155 | 156 | Extensions: map[string]interface{}{ 157 | common.ComposeExtensionNameXCasaOS: c.AppStoreInfo(), 158 | }, 159 | }, 160 | } 161 | } 162 | 163 | func (c *CustomizationPostData) Compose() codegen.ComposeApp { 164 | return codegen.ComposeApp{ 165 | Name: strings.ToLower(c.ContainerName), 166 | Services: c.Services(), 167 | Extensions: map[string]interface{}{ 168 | common.ComposeExtensionNameXCasaOS: c.ComposeAppStoreInfo(), 169 | }, 170 | } 171 | } 172 | 173 | func emtpySliceThenNil[T any](arr []T) []T { 174 | if len(arr) == 0 { 175 | return nil 176 | } 177 | 178 | return arr 179 | } 180 | -------------------------------------------------------------------------------- /model/manifest_adapter_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 11 | "github.com/IceWhaleTech/CasaOS-AppManagement/model" 12 | "github.com/IceWhaleTech/CasaOS-AppManagement/service" 13 | "github.com/stretchr/testify/assert" 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | func TestCompose(t *testing.T) { 18 | var legacyApp model.CustomizationPostData 19 | 20 | err := json.Unmarshal([]byte(common.SampleLegacyAppfileExportJSON), &legacyApp) 21 | assert.NoError(t, err) 22 | 23 | compose := legacyApp.Compose() 24 | assert.Equal(t, strings.ToLower(legacyApp.ContainerName), compose.Name) 25 | 26 | tmpDir, err := os.MkdirTemp("", "test-compose-*") 27 | assert.NoError(t, err) 28 | defer os.RemoveAll(tmpDir) 29 | 30 | buf, err := yaml.Marshal(compose) 31 | assert.NoError(t, err) 32 | 33 | yamlFilePath := filepath.Join(tmpDir, "docker-compose.yaml") 34 | err = os.WriteFile(yamlFilePath, buf, 0o600) 35 | assert.NoError(t, err) 36 | 37 | composeApp, err := service.LoadComposeAppFromConfigFile("test", yamlFilePath) 38 | assert.NoError(t, err) 39 | 40 | assert.NotNil(t, composeApp) 41 | } 42 | -------------------------------------------------------------------------------- /model/sys_common.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type CommonModel struct { 4 | RuntimePath string 5 | } 6 | 7 | type APPModel struct { 8 | LogPath string 9 | LogSaveName string 10 | LogFileExt string 11 | AppStorePath string 12 | AppsPath string 13 | } 14 | 15 | type ServerModel struct { 16 | AppStoreList []string `ini:"appstore,,allowshadow"` 17 | } 18 | 19 | type GlobalModel struct { 20 | OpenAIAPIKey string 21 | } 22 | 23 | type CasaOSGlobalVariables struct { 24 | AppChange bool 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@icewhale/casaos-appmanagement-openapi", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "clean": "rm -rf generate", 6 | "build": "rm -rf dist && tsc && yarn clean", 7 | "generate:local": "openapi-generator-cli generate -g typescript-axios -i ./api/app_management/openapi.yaml -o ./generate", 8 | "generate:npx": "npx @openapitools/openapi-generator-cli generate -g typescript-axios -i ./api/app_management/openapi.yaml -o ./generate", 9 | "generate:ts": "npx openapi-typescript-codegen --input ./api/app_management/openapi.yaml --output ./generate", 10 | "start": "yarn generate:local && yarn build" 11 | }, 12 | "homepage": "https://github.com/IceWhaleTech/CasaOS-AppManagement#readme", 13 | "description": "Casaos-AppManagement Typescript+Axios SDK", 14 | "keywords": [ 15 | "CasaOS-AppManagement", 16 | "SDK", 17 | "CasaOS-AppManagement Axios" 18 | ], 19 | "main": "dist/index.js", 20 | "files": [ 21 | "LICENSE", 22 | "README.md", 23 | "dist", 24 | "generate" 25 | ], 26 | "dependencies": { 27 | "axios": "^1.1.0" 28 | }, 29 | "devDependencies": { 30 | "@openapitools/openapi-generator-cli": "2.5.2", 31 | "@types/node": "^18.8.3", 32 | "openapi-typescript-codegen": "^0.23.0", 33 | "typescript": "^4.9.5" 34 | }, 35 | "author": "casaos", 36 | "license": "Apache-2.0" 37 | } -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/IceWhaleTech/CasaOS-Common/utils/constants" 7 | ) 8 | 9 | var ( 10 | AppManagementConfigFilePath = filepath.Join(constants.DefaultConfigPath, "app-management.conf") 11 | AppManagementGlobalEnvFilePath = filepath.Join(constants.DefaultConfigPath, "env") 12 | RemoveRuntimeIfNoNvidiaGPUFlag = false 13 | ) 14 | -------------------------------------------------------------------------------- /pkg/config/init.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 12 | "github.com/IceWhaleTech/CasaOS-AppManagement/model" 13 | "github.com/IceWhaleTech/CasaOS-Common/utils/constants" 14 | "gopkg.in/ini.v1" 15 | ) 16 | 17 | var ( 18 | CommonInfo = &model.CommonModel{ 19 | RuntimePath: constants.DefaultRuntimePath, 20 | } 21 | 22 | AppInfo = &model.APPModel{ 23 | AppStorePath: filepath.Join(constants.DefaultDataPath, "appstore"), 24 | AppsPath: filepath.Join(constants.DefaultDataPath, "apps"), 25 | LogPath: constants.DefaultLogPath, 26 | LogSaveName: common.AppManagementServiceName, 27 | LogFileExt: "log", 28 | } 29 | 30 | ServerInfo = &model.ServerModel{ 31 | AppStoreList: []string{}, 32 | } 33 | 34 | // Global is a map to inject environment variables to the app. 35 | Global = make(map[string]string) 36 | 37 | CasaOSGlobalVariables = &model.CasaOSGlobalVariables{} 38 | 39 | Cfg *ini.File 40 | ConfigFilePath string 41 | GlobalEnvFilePath string 42 | ) 43 | 44 | func ReloadConfig() { 45 | var err error 46 | Cfg, err = ini.LoadSources(ini.LoadOptions{Insensitive: true, AllowShadows: true}, ConfigFilePath) 47 | if err != nil { 48 | fmt.Println("failed to reload config", err) 49 | } else { 50 | mapTo("common", CommonInfo) 51 | mapTo("app", AppInfo) 52 | mapTo("server", ServerInfo) 53 | } 54 | } 55 | 56 | func InitSetup(config string, sample string) { 57 | ConfigFilePath = AppManagementConfigFilePath 58 | if len(config) > 0 { 59 | ConfigFilePath = config 60 | } 61 | 62 | // create default config file if not exist 63 | if _, err := os.Stat(ConfigFilePath); os.IsNotExist(err) { 64 | fmt.Println("config file not exist, create it") 65 | // create config file 66 | file, err := os.Create(ConfigFilePath) 67 | if err != nil { 68 | panic(err) 69 | } 70 | defer file.Close() 71 | 72 | // write default config 73 | _, err = file.WriteString(sample) 74 | if err != nil { 75 | panic(err) 76 | } 77 | } 78 | 79 | var err error 80 | 81 | Cfg, err = ini.LoadSources(ini.LoadOptions{Insensitive: true, AllowShadows: true}, ConfigFilePath) 82 | if err != nil { 83 | panic(err) 84 | } 85 | 86 | mapTo("common", CommonInfo) 87 | mapTo("app", AppInfo) 88 | mapTo("server", ServerInfo) 89 | } 90 | 91 | func SaveSetup() error { 92 | reflectFrom("common", CommonInfo) 93 | reflectFrom("app", AppInfo) 94 | reflectFrom("server", ServerInfo) 95 | 96 | return Cfg.SaveTo(ConfigFilePath) 97 | } 98 | 99 | func InitGlobal(config string) { 100 | // read file 101 | // file content like this: 102 | // OPENAI_API_KEY=123456 103 | 104 | // read file 105 | GlobalEnvFilePath = AppManagementGlobalEnvFilePath 106 | if len(config) > 0 { 107 | ConfigFilePath = config 108 | } 109 | 110 | // from file read key and value 111 | // set to Global 112 | file, err := os.Open(GlobalEnvFilePath) 113 | // there can't to panic err. because the env file is a new file 114 | // very much user didn't have the file. 115 | if err != nil { 116 | // log.Fatal will exit the program. So we only can to log the error. 117 | log.Println("open global env file error:", err) 118 | } else { 119 | defer file.Close() 120 | scanner := bufio.NewScanner(file) 121 | scanner.Split(bufio.ScanLines) 122 | 123 | for scanner.Scan() { 124 | line := scanner.Text() 125 | parts := strings.Split(line, "=") 126 | Global[parts[0]] = parts[1] 127 | } 128 | } 129 | } 130 | 131 | func SaveGlobal() error { 132 | // file content like this: 133 | // OPENAI_API_KEY=123456 134 | file, err := os.Create(AppManagementGlobalEnvFilePath) 135 | if err != nil { 136 | return err 137 | } 138 | defer file.Close() 139 | 140 | writer := bufio.NewWriter(file) 141 | 142 | for key, value := range Global { 143 | fmt.Fprintf(writer, "%s=%s\n", key, value) 144 | } 145 | 146 | writer.Flush() 147 | return err 148 | } 149 | 150 | func mapTo(section string, v interface{}) { 151 | err := Cfg.Section(section).MapTo(v) 152 | if err != nil { 153 | log.Fatalf("Cfg.MapTo %s err: %v", section, err) 154 | } 155 | } 156 | 157 | func reflectFrom(section string, v interface{}) { 158 | err := Cfg.Section(section).ReflectFrom(v) 159 | if err != nil { 160 | log.Fatalf("Cfg.ReflectFrom %s err: %v", section, err) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /pkg/docker/auth.go: -------------------------------------------------------------------------------- 1 | /* 2 | credit: https://github.com/containrrr/watchtower 3 | */ 4 | package docker 5 | 6 | import ( 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "net/url" 13 | "strings" 14 | 15 | "github.com/docker/distribution/reference" 16 | ) 17 | 18 | type TokenResponse struct { 19 | Token string `json:"token"` 20 | } 21 | 22 | // ChallengeHeader is the HTTP Header containing challenge instructions 23 | const ChallengeHeader = "WWW-Authenticate" 24 | 25 | // GetChallenge fetches a challenge for the registry hosting the provided image 26 | func GetChallenge(imageName string) (string, error) { 27 | var err error 28 | var URL url.URL 29 | 30 | if URL, err = GetChallengeURL(imageName); err != nil { 31 | return "", err 32 | } 33 | 34 | var req *http.Request 35 | if req, err = GetChallengeRequest(URL); err != nil { 36 | return "", err 37 | } 38 | 39 | client := http.Client{Transport: &http.Transport{DisableKeepAlives: true}} 40 | var res *http.Response 41 | if res, err = client.Do(req); err != nil { 42 | return "", err 43 | } 44 | defer res.Body.Close() 45 | 46 | v := res.Header.Get(ChallengeHeader) 47 | 48 | return strings.ToLower(v), nil 49 | } 50 | 51 | func GetToken(challenge string, registryAuth string, imageName string) (string, error) { 52 | if strings.HasPrefix(challenge, "basic") { 53 | if registryAuth == "" { 54 | return "", fmt.Errorf("no credentials available") 55 | } 56 | 57 | return fmt.Sprintf("Basic %s", registryAuth), nil 58 | } 59 | 60 | if strings.HasPrefix(challenge, "bearer") { 61 | return GetBearerHeader(challenge, imageName, registryAuth) 62 | } 63 | 64 | return "", errors.New("unsupported challenge type from registry") 65 | } 66 | 67 | // GetChallengeRequest creates a request for getting challenge instructions 68 | func GetChallengeRequest(URL url.URL) (*http.Request, error) { 69 | req, err := http.NewRequest(http.MethodGet, URL.String(), nil) 70 | if err != nil { 71 | return nil, err 72 | } 73 | req.Header.Set("Accept", "*/*") 74 | req.Header.Set("User-Agent", "CasaOS") 75 | return req, nil 76 | } 77 | 78 | // GetBearerHeader tries to fetch a bearer token from the registry based on the challenge instructions 79 | func GetBearerHeader(challenge string, img string, registryAuth string) (string, error) { 80 | client := http.Client{Transport: &http.Transport{DisableKeepAlives: true}} 81 | if strings.Contains(img, ":") { 82 | img = strings.Split(img, ":")[0] 83 | } 84 | 85 | authURL, err := GetAuthURL(challenge, img) 86 | if err != nil { 87 | return "", err 88 | } 89 | 90 | var r *http.Request 91 | if r, err = http.NewRequest(http.MethodGet, authURL.String(), nil); err != nil { 92 | return "", err 93 | } 94 | 95 | if registryAuth != "" { 96 | r.Header.Add("Authorization", fmt.Sprintf("Basic %s", registryAuth)) 97 | } 98 | 99 | var authResponse *http.Response 100 | if authResponse, err = client.Do(r); err != nil { 101 | return "", err 102 | } 103 | defer authResponse.Body.Close() 104 | 105 | tokenResponse := &TokenResponse{} 106 | 107 | body, err := io.ReadAll(authResponse.Body) 108 | if err != nil { 109 | return "", err 110 | } 111 | 112 | err = json.Unmarshal(body, tokenResponse) 113 | if err != nil { 114 | return "", err 115 | } 116 | 117 | return fmt.Sprintf("Bearer %s", tokenResponse.Token), nil 118 | } 119 | 120 | // GetAuthURL from the instructions in the challenge 121 | func GetAuthURL(challenge string, img string) (*url.URL, error) { 122 | loweredChallenge := strings.ToLower(challenge) 123 | raw := strings.TrimPrefix(loweredChallenge, "bearer") 124 | 125 | pairs := strings.Split(raw, ",") 126 | values := make(map[string]string, len(pairs)) 127 | 128 | for _, pair := range pairs { 129 | trimmed := strings.Trim(pair, " ") 130 | kv := strings.Split(trimmed, "=") 131 | key := kv[0] 132 | val := strings.Trim(kv[1], "\"") 133 | values[key] = val 134 | } 135 | 136 | if values["realm"] == "" || values["service"] == "" { 137 | return nil, fmt.Errorf("challenge header did not include all values needed to construct an auth url") 138 | } 139 | 140 | authURL, _ := url.Parse(values["realm"]) 141 | q := authURL.Query() 142 | q.Add("service", values["service"]) 143 | 144 | scopeImage := GetScopeFromImageName(img, values["service"]) 145 | 146 | scope := fmt.Sprintf("repository:%s:pull", scopeImage) 147 | q.Add("scope", scope) 148 | 149 | authURL.RawQuery = q.Encode() 150 | return authURL, nil 151 | } 152 | 153 | // GetScopeFromImageName normalizes an image name for use as scope during auth and head requests 154 | func GetScopeFromImageName(img, svc string) string { 155 | parts := strings.Split(img, "/") 156 | 157 | if len(parts) > 2 { 158 | if strings.Contains(svc, "docker.io") { 159 | return fmt.Sprintf("%s/%s", parts[1], strings.Join(parts[2:], "/")) 160 | } 161 | return strings.Join(parts, "/") 162 | } 163 | 164 | if len(parts) == 2 { 165 | if strings.Contains(parts[0], "docker.io") { 166 | return fmt.Sprintf("library/%s", parts[1]) 167 | } 168 | return strings.Replace(img, svc+"/", "", 1) 169 | } 170 | 171 | if strings.Contains(svc, "docker.io") { 172 | return fmt.Sprintf("library/%s", parts[0]) 173 | } 174 | return img 175 | } 176 | 177 | // GetChallengeURL creates a URL object based on the image info 178 | func GetChallengeURL(img string) (url.URL, error) { 179 | normalizedNamed, _ := reference.ParseNormalizedNamed(img) 180 | host, err := NormalizeRegistry(normalizedNamed.String()) 181 | if err != nil { 182 | return url.URL{}, err 183 | } 184 | 185 | URL := url.URL{ 186 | Scheme: "https", 187 | Host: host, 188 | Path: "/v2/", 189 | } 190 | return URL, nil 191 | } 192 | -------------------------------------------------------------------------------- /pkg/docker/container.go: -------------------------------------------------------------------------------- 1 | /* 2 | credit: https://github.com/containrrr/watchtower 3 | */ 4 | package docker 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "strings" 10 | 11 | "github.com/IceWhaleTech/CasaOS-Common/utils" 12 | "github.com/docker/docker/api/types" 13 | "github.com/docker/docker/api/types/container" 14 | "github.com/docker/docker/api/types/network" 15 | "github.com/docker/docker/client" 16 | "github.com/samber/lo" 17 | ) 18 | 19 | func ImageName(containerInfo *types.ContainerJSON) string { 20 | imageName := containerInfo.Config.Image 21 | 22 | if !strings.Contains(imageName, ":") { 23 | imageName = imageName + ":latest" 24 | } 25 | 26 | return imageName 27 | } 28 | 29 | func Container(ctx context.Context, id string) (*types.ContainerJSON, error) { 30 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 31 | if err != nil { 32 | return nil, err 33 | } 34 | defer cli.Close() 35 | 36 | containerInfo, err := cli.ContainerInspect(ctx, id) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | return &containerInfo, nil 42 | } 43 | 44 | func CloneContainer(ctx context.Context, id string, newName string) (string, error) { 45 | containerInfo, err := Container(ctx, id) 46 | if err != nil { 47 | return "", err 48 | } 49 | 50 | imageInfo, err := Image(ctx, containerInfo.Image) 51 | if err != nil { 52 | return "", err 53 | } 54 | 55 | config := runtimeConfig(containerInfo, imageInfo) 56 | hostConfig := hostConfig(containerInfo) 57 | networkConfig := &network.NetworkingConfig{EndpointsConfig: containerInfo.NetworkSettings.Networks} 58 | simpleNetworkConfig := simpleNetworkConfig(networkConfig) 59 | 60 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 61 | if err != nil { 62 | return "", err 63 | } 64 | defer cli.Close() 65 | 66 | newContainer, err := cli.ContainerCreate(ctx, config, hostConfig, simpleNetworkConfig, nil, newName) 67 | if err != nil { 68 | return "", err 69 | } 70 | 71 | if !(hostConfig.NetworkMode.IsHost()) { 72 | for k := range simpleNetworkConfig.EndpointsConfig { 73 | if err := cli.NetworkDisconnect(ctx, k, newContainer.ID, true); err != nil { 74 | return newContainer.ID, err 75 | } 76 | } 77 | 78 | for k, v := range networkConfig.EndpointsConfig { 79 | if err := cli.NetworkConnect(ctx, k, newContainer.ID, v); err != nil { 80 | return newContainer.ID, err 81 | } 82 | } 83 | } 84 | 85 | return newContainer.ID, nil 86 | } 87 | 88 | func RemoveContainer(ctx context.Context, id string) error { 89 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 90 | if err != nil { 91 | return err 92 | } 93 | defer cli.Close() 94 | 95 | return cli.ContainerRemove(ctx, id, types.ContainerRemoveOptions{Force: true}) 96 | } 97 | 98 | func RenameContainer(ctx context.Context, id string, name string) error { 99 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 100 | if err != nil { 101 | return err 102 | } 103 | defer cli.Close() 104 | 105 | return cli.ContainerRename(ctx, id, name) 106 | } 107 | 108 | func StartContainer(ctx context.Context, id string) error { 109 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 110 | if err != nil { 111 | return err 112 | } 113 | defer cli.Close() 114 | 115 | containerInfo, err := cli.ContainerInspect(ctx, id) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | if !containerInfo.State.Running { 121 | return cli.ContainerStart(ctx, id, types.ContainerStartOptions{}) 122 | } 123 | 124 | return nil 125 | } 126 | 127 | func StopContainer(ctx context.Context, id string) error { 128 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 129 | if err != nil { 130 | return err 131 | } 132 | defer cli.Close() 133 | 134 | containerInfo, err := cli.ContainerInspect(ctx, id) 135 | if err != nil { 136 | return err 137 | } 138 | 139 | if containerInfo.State.Running { 140 | if err := cli.ContainerStop(ctx, id, container.StopOptions{}); err != nil { 141 | return err 142 | } 143 | 144 | if err := WaitContainer(ctx, id, container.WaitConditionNotRunning); err != nil { 145 | return err 146 | } 147 | } 148 | 149 | return nil 150 | } 151 | 152 | func WaitContainer(ctx context.Context, id string, condition container.WaitCondition) error { 153 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 154 | if err != nil { 155 | return err 156 | } 157 | defer cli.Close() 158 | 159 | wait, errChan := cli.ContainerWait(ctx, id, condition) 160 | for { 161 | select { 162 | case err := <-errChan: 163 | return err 164 | case <-wait: 165 | return nil 166 | } 167 | } 168 | } 169 | 170 | func runtimeConfig(containerInfo *types.ContainerJSON, imageInfo *types.ImageInspect) *container.Config { 171 | config := containerInfo.Config 172 | hostConfig := containerInfo.HostConfig 173 | imageConfig := imageInfo.Config 174 | 175 | if config.WorkingDir == imageConfig.WorkingDir { 176 | config.WorkingDir = "" 177 | } 178 | 179 | if config.User == imageConfig.User { 180 | config.User = "" 181 | } 182 | 183 | if hostConfig.NetworkMode.IsContainer() { 184 | config.Hostname = "" 185 | } 186 | 187 | if utils.CompareStringSlices(config.Entrypoint, imageConfig.Entrypoint) { 188 | config.Entrypoint = nil 189 | if utils.CompareStringSlices(config.Cmd, imageConfig.Cmd) { 190 | config.Cmd = nil 191 | } 192 | } 193 | 194 | config.Env = lo.Filter(config.Env, func(s string, i int) bool { return !lo.Contains(imageConfig.Env, s) }) 195 | 196 | config.Labels = lo.OmitBy(config.Labels, func(k string, v string) bool { 197 | v2, ok := imageConfig.Labels[k] 198 | return ok && v == v2 199 | }) 200 | 201 | config.Volumes = lo.OmitBy(config.Volumes, func(k string, v struct{}) bool { 202 | v2, ok := imageConfig.Volumes[k] 203 | return ok && v == v2 204 | }) 205 | 206 | // subtract ports exposed in image from container 207 | for k := range config.ExposedPorts { 208 | if _, ok := imageConfig.ExposedPorts[k]; ok { 209 | delete(config.ExposedPorts, k) 210 | } 211 | } 212 | 213 | for p := range containerInfo.HostConfig.PortBindings { 214 | config.ExposedPorts[p] = struct{}{} 215 | } 216 | 217 | config.Image = ImageName(containerInfo) 218 | return config 219 | } 220 | 221 | func hostConfig(containerInfo *types.ContainerJSON) *container.HostConfig { 222 | hostConfig := containerInfo.HostConfig 223 | 224 | for i, link := range hostConfig.Links { 225 | name := link[0:strings.Index(link, ":")] 226 | alias := link[strings.LastIndex(link, "/"):] 227 | 228 | hostConfig.Links[i] = fmt.Sprintf("%s:%s", name, alias) 229 | } 230 | 231 | return hostConfig 232 | } 233 | 234 | // simpleNetworkConfig is a networkConfig with only 1 network. 235 | // see: https://github.com/docker/docker/issues/29265 236 | func simpleNetworkConfig(networkConfig *network.NetworkingConfig) *network.NetworkingConfig { 237 | oneEndpoint := make(map[string]*network.EndpointSettings) 238 | for k, v := range networkConfig.EndpointsConfig { 239 | oneEndpoint[k] = v 240 | // we only need 1 241 | break 242 | } 243 | return &network.NetworkingConfig{EndpointsConfig: oneEndpoint} 244 | } 245 | -------------------------------------------------------------------------------- /pkg/docker/container_test.go: -------------------------------------------------------------------------------- 1 | package docker_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "runtime" 7 | "testing" 8 | 9 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/docker" 10 | "github.com/IceWhaleTech/CasaOS-Common/utils/random" 11 | "github.com/docker/docker/api/types" 12 | "github.com/docker/docker/api/types/container" 13 | "github.com/docker/docker/api/types/network" 14 | "github.com/docker/docker/client" 15 | "github.com/samber/lo" 16 | "go.uber.org/goleak" 17 | "gotest.tools/v3/assert" 18 | ) 19 | 20 | func setupTestContainer(ctx context.Context, t *testing.T) *container.CreateResponse { 21 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 22 | assert.NilError(t, err) 23 | defer cli.Close() 24 | 25 | imageName := "alpine:latest" 26 | 27 | config := &container.Config{ 28 | Image: imageName, 29 | Cmd: []string{"tail", "-f", "/dev/null"}, 30 | Env: []string{"FOO=BAR"}, 31 | } 32 | 33 | hostConfig := &container.HostConfig{} 34 | networkingConfig := &network.NetworkingConfig{} 35 | 36 | out, err := cli.ImagePull(ctx, imageName, types.ImagePullOptions{}) 37 | assert.NilError(t, err) 38 | 39 | _, err = io.ReadAll(out) 40 | assert.NilError(t, err) 41 | 42 | response, err := cli.ContainerCreate(ctx, config, hostConfig, networkingConfig, nil, "test-"+random.RandomString(4, false)) 43 | assert.NilError(t, err) 44 | 45 | return &response 46 | } 47 | 48 | func TestCloneContainer(t *testing.T) { 49 | defer goleak.VerifyNone(t) 50 | 51 | defer func() { 52 | // workaround due to https://github.com/patrickmn/go-cache/issues/166 53 | docker.Cache = nil 54 | runtime.GC() 55 | }() 56 | 57 | if !docker.IsDaemonRunning() { 58 | t.Skip("Docker daemon is not running") 59 | } 60 | 61 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 62 | assert.NilError(t, err) 63 | defer cli.Close() 64 | 65 | ctx := context.Background() 66 | 67 | // setup 68 | response := setupTestContainer(ctx, t) 69 | 70 | defer func() { 71 | err = cli.ContainerRemove(ctx, response.ID, types.ContainerRemoveOptions{}) 72 | assert.NilError(t, err) 73 | }() 74 | 75 | err = docker.StartContainer(ctx, response.ID) 76 | assert.NilError(t, err) 77 | 78 | defer func() { 79 | err = docker.StopContainer(ctx, response.ID) 80 | assert.NilError(t, err) 81 | }() 82 | 83 | newID, err := docker.CloneContainer(ctx, response.ID, "test-"+random.RandomString(4, false)) 84 | assert.NilError(t, err) 85 | 86 | defer func() { 87 | err := docker.RemoveContainer(ctx, newID) 88 | assert.NilError(t, err) 89 | }() 90 | 91 | err = docker.StartContainer(ctx, newID) 92 | assert.NilError(t, err) 93 | 94 | defer func() { 95 | err := docker.StopContainer(ctx, newID) 96 | assert.NilError(t, err) 97 | }() 98 | 99 | containerInfo, err := docker.Container(ctx, newID) 100 | assert.NilError(t, err) 101 | assert.Assert(t, lo.Contains(containerInfo.Config.Env, "FOO=BAR")) 102 | } 103 | 104 | func TestNonExistingContainer(t *testing.T) { 105 | containerInfo, err := docker.Container(context.Background(), "non-existing-container") 106 | assert.ErrorContains(t, err, "non-existing-container") 107 | assert.Assert(t, containerInfo == nil) 108 | } 109 | -------------------------------------------------------------------------------- /pkg/docker/daemon.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/docker/docker/client" 7 | ) 8 | 9 | func IsDaemonRunning() bool { 10 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 11 | if err != nil { 12 | return false 13 | } 14 | defer cli.Close() 15 | 16 | _, err = cli.Ping(context.Background()) 17 | return err == nil 18 | } 19 | 20 | func CurrentArchitecture() (string, error) { 21 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 22 | if err != nil { 23 | return "", err 24 | } 25 | defer cli.Close() 26 | 27 | ver, err := cli.ServerVersion(context.Background()) 28 | if err != nil { 29 | return "", err 30 | } 31 | 32 | return ver.Arch, nil 33 | } 34 | -------------------------------------------------------------------------------- /pkg/docker/daemon_test.go: -------------------------------------------------------------------------------- 1 | package docker_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/docker" 7 | "gotest.tools/v3/assert" 8 | ) 9 | 10 | func TestCurrentArchitecture(t *testing.T) { 11 | a, err := docker.CurrentArchitecture() 12 | assert.NilError(t, err) 13 | assert.Assert(t, a != "") 14 | } 15 | -------------------------------------------------------------------------------- /pkg/docker/digest.go: -------------------------------------------------------------------------------- 1 | /* 2 | credit: https://github.com/containrrr/watchtower 3 | */ 4 | package docker 5 | 6 | import ( 7 | "context" 8 | "crypto/tls" 9 | "encoding/base64" 10 | "encoding/json" 11 | "errors" 12 | "fmt" 13 | "io/ioutil" 14 | "net" 15 | "net/http" 16 | "strings" 17 | "time" 18 | 19 | "github.com/docker/distribution/manifest" 20 | "github.com/docker/distribution/manifest/manifestlist" 21 | "github.com/docker/distribution/manifest/schema1" 22 | "github.com/docker/distribution/manifest/schema2" 23 | v1 "github.com/opencontainers/image-spec/specs-go/v1" 24 | ) 25 | 26 | // RegistryCredentials is a credential pair used for basic auth 27 | type RegistryCredentials struct { 28 | Username string 29 | Password string // usually a token rather than an actual password 30 | } 31 | 32 | // ContentDigestHeader is the key for the key-value pair containing the digest header 33 | const ContentDigestHeader = "Docker-Content-Digest" 34 | 35 | // CompareDigest ... 36 | func CompareDigest(imageName string, repoDigests []string) (bool, error) { 37 | var digest string 38 | 39 | token, url, err := tokenAndURL(imageName) 40 | if err != nil { 41 | return false, err 42 | } 43 | 44 | if digest, err = GetDigest(url, token); err != nil { 45 | return false, err 46 | } 47 | 48 | for _, dig := range repoDigests { 49 | localDigest := strings.Split(dig, "@")[1] 50 | 51 | if localDigest == digest { 52 | return true, nil 53 | } 54 | } 55 | 56 | return false, nil 57 | } 58 | 59 | // TransformAuth from a base64 encoded json object to base64 encoded string 60 | func TransformAuth(registryAuth string) string { 61 | b, _ := base64.StdEncoding.DecodeString(registryAuth) 62 | credentials := &RegistryCredentials{} 63 | _ = json.Unmarshal(b, credentials) 64 | 65 | if credentials.Username != "" && credentials.Password != "" { 66 | ba := []byte(fmt.Sprintf("%s:%s", credentials.Username, credentials.Password)) 67 | registryAuth = base64.StdEncoding.EncodeToString(ba) 68 | } 69 | 70 | return registryAuth 71 | } 72 | 73 | // GetDigest from registry using a HEAD request to prevent rate limiting 74 | func GetDigest(url string, token string) (string, error) { 75 | if token == "" { 76 | return "", errors.New("could not fetch token") 77 | } 78 | 79 | req, _ := http.NewRequest(http.MethodHead, url, nil) 80 | addDefaultHeaders(&req.Header, token) 81 | 82 | res, err := httpClient().Do(req) 83 | if err != nil { 84 | return "", err 85 | } 86 | defer res.Body.Close() 87 | 88 | if res.StatusCode != http.StatusOK { 89 | wwwAuthHeader := res.Header.Get("www-authenticate") 90 | if wwwAuthHeader == "" { 91 | wwwAuthHeader = "not present" 92 | } 93 | return "", fmt.Errorf("registry responded to head request with %q, auth: %q", res.Status, wwwAuthHeader) 94 | } 95 | return res.Header.Get(ContentDigestHeader), nil 96 | } 97 | 98 | func GetManifest(ctx context.Context, imageName string) (interface{}, string, error) { 99 | token, url, err := tokenAndURL(imageName) 100 | if err != nil { 101 | return nil, "", err 102 | } 103 | 104 | req, _ := http.NewRequest(http.MethodGet, url, nil) 105 | req = req.WithContext(ctx) 106 | addDefaultHeaders(&req.Header, token) 107 | 108 | res, err := httpClient().Do(req) 109 | if err != nil { 110 | return nil, "", err 111 | } 112 | defer res.Body.Close() 113 | 114 | if res.StatusCode != http.StatusOK { 115 | return nil, "", fmt.Errorf("registry responded to head request with %q", res.Status) 116 | } 117 | 118 | contentType := res.Header.Get("Content-Type") 119 | 120 | buf, err := ioutil.ReadAll(res.Body) 121 | if err != nil { 122 | return nil, "", err 123 | } 124 | 125 | var baseManifest manifest.Versioned 126 | if err := json.Unmarshal(buf, &baseManifest); err != nil { 127 | return nil, contentType, fmt.Errorf("not a manifest content: %w", err) 128 | } 129 | 130 | manifest, ok := map[string]interface{}{ 131 | schema1.MediaTypeSignedManifest: schema1.SignedManifest{}, 132 | schema2.MediaTypeManifest: schema2.Manifest{}, 133 | manifestlist.MediaTypeManifestList: manifestlist.ManifestList{}, 134 | v1.MediaTypeImageIndex: manifestlist.ManifestList{}, 135 | }[contentType] 136 | 137 | if !ok { 138 | return nil, contentType, fmt.Errorf("unknown content type: %s", contentType) 139 | } 140 | 141 | if err := json.Unmarshal(buf, &manifest); err != nil { 142 | return nil, "", err 143 | } 144 | 145 | return manifest, contentType, nil 146 | } 147 | 148 | func tokenAndURL(imageName string) (string, string, error) { 149 | opts, err := GetPullOptions(imageName) 150 | if err != nil { 151 | return "", "", err 152 | } 153 | 154 | registryAuth := TransformAuth(opts.RegistryAuth) 155 | challenge, err := GetChallenge(imageName) 156 | if err != nil { 157 | return "", "", err 158 | } 159 | 160 | token, err := GetToken(challenge, registryAuth, imageName) 161 | if err != nil { 162 | return "", "", err 163 | } 164 | 165 | url, err := BuildManifestURL(imageName) 166 | if err != nil { 167 | return "", "", err 168 | } 169 | 170 | return token, url, nil 171 | } 172 | 173 | func httpClient() *http.Client { 174 | return &http.Client{Transport: &http.Transport{ 175 | DialContext: (&net.Dialer{ 176 | Timeout: 30 * time.Second, 177 | KeepAlive: 30 * time.Second, 178 | }).DialContext, 179 | DisableKeepAlives: true, 180 | ExpectContinueTimeout: 1 * time.Second, 181 | ForceAttemptHTTP2: true, 182 | IdleConnTimeout: 90 * time.Second, 183 | MaxIdleConns: 100, 184 | Proxy: http.ProxyFromEnvironment, 185 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // nolint:gosec 186 | TLSHandshakeTimeout: 10 * time.Second, 187 | }} 188 | } 189 | 190 | func addDefaultHeaders(header *http.Header, token string) { 191 | header.Add("Authorization", token) 192 | // header.Add("Accept", schema2.MediaTypeManifest) 193 | header.Add("Accept", manifestlist.MediaTypeManifestList) 194 | // header.Add("Accept", schema1.MediaTypeManifest) 195 | header.Add("Accept", v1.MediaTypeImageIndex) 196 | } 197 | -------------------------------------------------------------------------------- /pkg/docker/digest_test.go: -------------------------------------------------------------------------------- 1 | package docker_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "runtime" 7 | "testing" 8 | 9 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/docker" 10 | "github.com/docker/distribution/manifest/manifestlist" 11 | "github.com/docker/distribution/manifest/schema1" 12 | "github.com/docker/docker/api/types" 13 | "github.com/docker/docker/client" 14 | "github.com/mitchellh/mapstructure" 15 | "github.com/samber/lo" 16 | "go.uber.org/goleak" 17 | "gotest.tools/v3/assert" 18 | 19 | v1 "github.com/opencontainers/image-spec/specs-go/v1" 20 | ) 21 | 22 | func TestCompareDigest(t *testing.T) { 23 | defer goleak.VerifyNone(t) 24 | 25 | defer func() { 26 | // workaround due to https://github.com/patrickmn/go-cache/issues/166 27 | docker.Cache = nil 28 | runtime.GC() 29 | }() 30 | 31 | if !docker.IsDaemonRunning() { 32 | t.Skip("Docker daemon is not running") 33 | } 34 | 35 | defer func() { 36 | // workaround due to https://github.com/patrickmn/go-cache/issues/166 37 | docker.Cache = nil 38 | runtime.GC() 39 | }() 40 | 41 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 42 | assert.NilError(t, err) 43 | defer cli.Close() 44 | 45 | ctx := context.Background() 46 | 47 | imageName := "alpine:latest" 48 | 49 | out, err := cli.ImagePull(ctx, imageName, types.ImagePullOptions{}) 50 | assert.NilError(t, err) 51 | defer out.Close() 52 | 53 | str, err := io.ReadAll(out) 54 | assert.NilError(t, err) 55 | 56 | t.Log(string(str)) 57 | 58 | imageInfo, _, err := cli.ImageInspectWithRaw(ctx, imageName) 59 | assert.NilError(t, err) 60 | 61 | match, err := docker.CompareDigest(imageName, imageInfo.RepoDigests) 62 | assert.NilError(t, err) 63 | 64 | assert.Assert(t, match) 65 | } 66 | 67 | func TestGetManifest1(t *testing.T) { 68 | defer goleak.VerifyNone(t) 69 | 70 | defer func() { 71 | // workaround due to https://github.com/patrickmn/go-cache/issues/166 72 | docker.Cache = nil 73 | runtime.GC() 74 | }() 75 | 76 | manifest, contentType, err := docker.GetManifest(context.Background(), "hello-world:nanoserver-1803") 77 | assert.NilError(t, err) 78 | assert.Equal(t, contentType, manifestlist.MediaTypeManifestList) 79 | 80 | var listManifest manifestlist.ManifestList 81 | decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &listManifest, Squash: true}) 82 | assert.NilError(t, err) 83 | 84 | err = decoder.Decode(manifest) 85 | assert.NilError(t, err) 86 | 87 | architectures := lo.Map(listManifest.Manifests, func(m manifestlist.ManifestDescriptor, i int) string { 88 | return m.Platform.Architecture 89 | }) 90 | 91 | architectures = lo.Filter(architectures, func(a string, i int) bool { 92 | return a != "" 93 | }) 94 | 95 | assert.Assert(t, len(architectures) > 0) 96 | } 97 | 98 | func TestGetManifest2(t *testing.T) { 99 | defer goleak.VerifyNone(t) 100 | 101 | defer func() { 102 | // workaround due to https://github.com/patrickmn/go-cache/issues/166 103 | docker.Cache = nil 104 | runtime.GC() 105 | }() 106 | 107 | manifest, contentType, err := docker.GetManifest(context.Background(), "correctroad/logseq:latest") 108 | assert.NilError(t, err) 109 | assert.Equal(t, contentType, "application/vnd.docker.distribution.manifest.v2+json") 110 | 111 | var signedManifest schema1.SignedManifest 112 | decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &signedManifest, Squash: true}) 113 | assert.NilError(t, err) 114 | 115 | err = decoder.Decode(manifest) 116 | assert.NilError(t, err) 117 | 118 | } 119 | 120 | func TestGetManifest3(t *testing.T) { 121 | defer goleak.VerifyNone(t) 122 | 123 | defer func() { 124 | // workaround due to https://github.com/patrickmn/go-cache/issues/166 125 | docker.Cache = nil 126 | runtime.GC() 127 | }() 128 | 129 | manifest, contentType, err := docker.GetManifest(context.Background(), "2fauth/2fauth:latest") 130 | assert.NilError(t, err) 131 | assert.Equal(t, contentType, v1.MediaTypeImageIndex) 132 | 133 | var listManifest manifestlist.ManifestList 134 | decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &listManifest, Squash: true}) 135 | assert.NilError(t, err) 136 | 137 | err = decoder.Decode(manifest) 138 | assert.NilError(t, err) 139 | 140 | architectures := lo.Map(listManifest.Manifests, func(m manifestlist.ManifestDescriptor, i int) string { 141 | return m.Platform.Architecture 142 | }) 143 | 144 | architectures = lo.Uniq(architectures) 145 | 146 | architectures = lo.Filter(architectures, func(a string, i int) bool { 147 | return a != "unknown" && a != "" 148 | }) 149 | 150 | assert.Assert(t, len(architectures) > 0) 151 | } 152 | -------------------------------------------------------------------------------- /pkg/docker/emum.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | const NETWORKNAME = "oasis" 4 | -------------------------------------------------------------------------------- /pkg/docker/helpers.go: -------------------------------------------------------------------------------- 1 | /* 2 | credit: https://github.com/containrrr/watchtower 3 | */ 4 | package docker 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | url2 "net/url" 10 | "time" 11 | 12 | "github.com/docker/distribution/manifest/manifestlist" 13 | "github.com/docker/distribution/manifest/schema1" 14 | "github.com/mitchellh/mapstructure" 15 | "github.com/patrickmn/go-cache" 16 | "github.com/samber/lo" 17 | ) 18 | 19 | var Cache *cache.Cache 20 | 21 | func init() { 22 | Cache = cache.New(5*time.Minute, 60*time.Second) 23 | } 24 | 25 | // ConvertToHostname strips a url from everything but the hostname part 26 | func ConvertToHostname(url string) (string, string, error) { 27 | urlWithSchema := fmt.Sprintf("x://%s", url) 28 | u, err := url2.Parse(urlWithSchema) 29 | if err != nil { 30 | return "", "", err 31 | } 32 | hostName := u.Hostname() 33 | port := u.Port() 34 | 35 | return hostName, port, err 36 | } 37 | 38 | // NormalizeRegistry makes sure variations of DockerHubs registry 39 | func NormalizeRegistry(registry string) (string, error) { 40 | hostName, port, err := ConvertToHostname(registry) 41 | if err != nil { 42 | return "", err 43 | } 44 | 45 | if hostName == "registry-1.docker.io" || hostName == "docker.io" { 46 | hostName = "index.docker.io" 47 | } 48 | 49 | if port != "" { 50 | return fmt.Sprintf("%s:%s", hostName, port), nil 51 | } 52 | return hostName, nil 53 | } 54 | 55 | func GetArchitectures(imageName string, noCache bool) ([]string, error) { 56 | cacheKey := imageName + ":architectures" 57 | if !noCache && Cache != nil { 58 | if cached, ok := Cache.Get(cacheKey); ok { 59 | if archs, ok := cached.([]string); ok { 60 | return archs, nil 61 | } 62 | } 63 | } 64 | 65 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 66 | defer cancel() 67 | 68 | manfest, contentType, err := GetManifest(ctx, imageName) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | fmt.Printf("got manifest - image: %s, contentType: %s", imageName, contentType) 74 | 75 | var architectures []string 76 | 77 | architectures, err = tryGetArchitecturesFromManifestList(manfest) 78 | if err != nil { 79 | fmt.Printf("failed to get architectures from manifest list: %v", err) 80 | } 81 | 82 | if len(architectures) == 0 { 83 | architectures, err = tryGetArchitecturesFromV1SignedManifest(manfest) 84 | if err != nil { 85 | fmt.Printf("failed to get architectures from v1 signed manifest: %v", err) 86 | } 87 | } 88 | 89 | if Cache != nil && len(architectures) > 0 { 90 | Cache.Set(cacheKey, architectures, 4*time.Hour) 91 | } else { 92 | fmt.Println("WARNING: cache is not initialized - will still be getting container image manifest from network next time.") 93 | } 94 | 95 | return architectures, nil 96 | } 97 | 98 | func tryGetArchitecturesFromManifestList(manifest interface{}) ([]string, error) { 99 | var listManifest manifestlist.ManifestList 100 | decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &listManifest, Squash: true}) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | if err := decoder.Decode(manifest); err != nil { 106 | return nil, err 107 | } 108 | 109 | architectures := []string{} 110 | for _, platform := range listManifest.Manifests { 111 | if platform.Platform.Architecture == "" || platform.Platform.Architecture == "unknown" { 112 | continue 113 | } 114 | 115 | architectures = append(architectures, platform.Platform.Architecture) 116 | } 117 | 118 | architectures = lo.Uniq(architectures) 119 | 120 | return architectures, nil 121 | } 122 | 123 | func tryGetArchitecturesFromV1SignedManifest(manifest interface{}) ([]string, error) { 124 | var signedManifest schema1.SignedManifest 125 | decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &signedManifest, Squash: true}) 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | if err := decoder.Decode(manifest); err != nil { 131 | return nil, err 132 | } 133 | 134 | if signedManifest.Architecture == "" || signedManifest.Architecture == "unknown" { 135 | return []string{"amd64"}, nil // bad assumption, but works for 99% of the cases 136 | } 137 | 138 | return []string{signedManifest.Architecture}, nil 139 | } 140 | -------------------------------------------------------------------------------- /pkg/docker/image.go: -------------------------------------------------------------------------------- 1 | /* 2 | credit: https://github.com/containrrr/watchtower 3 | */ 4 | package docker 5 | 6 | import ( 7 | "context" 8 | "io" 9 | 10 | "github.com/docker/docker/api/types" 11 | "github.com/docker/docker/client" 12 | ) 13 | 14 | func Image(ctx context.Context, imageName string) (*types.ImageInspect, error) { 15 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 16 | if err != nil { 17 | return nil, err 18 | } 19 | defer cli.Close() 20 | 21 | imageInfo, _, err := cli.ImageInspectWithRaw(ctx, imageName) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | return &imageInfo, nil 27 | } 28 | 29 | func PullImage(ctx context.Context, imageName string, handleOut func(io.ReadCloser)) error { 30 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 31 | if err != nil { 32 | return err 33 | } 34 | defer cli.Close() 35 | 36 | opts, err := GetPullOptions(imageName) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | out, err := cli.ImagePull(ctx, imageName, opts) 42 | if err != nil { 43 | return err 44 | } 45 | defer out.Close() 46 | 47 | if handleOut != nil { 48 | handleOut(out) 49 | } else { 50 | if _, err := io.ReadAll(out); err != nil { 51 | return err 52 | } 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func HasNewImage(ctx context.Context, imageName string, currentImageID string) (bool, string, error) { 59 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 60 | if err != nil { 61 | return false, currentImageID, err 62 | } 63 | defer cli.Close() 64 | 65 | newImageInfo, _, err := cli.ImageInspectWithRaw(ctx, imageName) 66 | if err != nil { 67 | return false, currentImageID, err 68 | } 69 | 70 | newImageID := newImageInfo.ID 71 | if newImageID == currentImageID { 72 | return false, currentImageID, nil 73 | } 74 | 75 | return true, newImageID, nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/docker/manifest.go: -------------------------------------------------------------------------------- 1 | /* 2 | credit: https://github.com/containrrr/watchtower 3 | */ 4 | package docker 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | 10 | url2 "net/url" 11 | 12 | ref "github.com/docker/distribution/reference" 13 | ) 14 | 15 | // BuildManifestURL from raw image data 16 | func BuildManifestURL(imageName string) (string, error) { 17 | normalizedName, err := ref.ParseNormalizedNamed(imageName) 18 | if err != nil { 19 | return "", err 20 | } 21 | 22 | host, err := NormalizeRegistry(normalizedName.String()) 23 | img, tag := ExtractImageAndTag(strings.TrimPrefix(imageName, host+"/")) 24 | 25 | if err != nil { 26 | return "", err 27 | } 28 | img = GetScopeFromImageName(img, host) 29 | 30 | if !strings.Contains(img, "/") { 31 | img = "library/" + img 32 | } 33 | url := url2.URL{ 34 | Scheme: "https", 35 | Host: host, 36 | Path: fmt.Sprintf("/v2/%s/manifests/%s", img, tag), 37 | } 38 | return url.String(), nil 39 | } 40 | 41 | // ExtractImageAndTag from a concatenated string 42 | func ExtractImageAndTag(imageName string) (string, string) { 43 | var img string 44 | var tag string 45 | 46 | if strings.Contains(imageName, ":") { 47 | parts := strings.Split(imageName, ":") 48 | if len(parts) > 2 { 49 | img = parts[0] 50 | tag = strings.Join(parts[1:], ":") 51 | } else { 52 | img = parts[0] 53 | tag = parts[1] 54 | } 55 | } else { 56 | img = imageName 57 | tag = "latest" 58 | } 59 | return img, tag 60 | } 61 | -------------------------------------------------------------------------------- /pkg/docker/registry.go: -------------------------------------------------------------------------------- 1 | /* 2 | credit: https://github.com/containrrr/watchtower 3 | */ 4 | package docker 5 | 6 | import ( 7 | "github.com/docker/docker/api/types" 8 | ) 9 | 10 | // GetPullOptions creates a struct with all options needed for pulling images from a registry 11 | func GetPullOptions(imageName string) (types.ImagePullOptions, error) { 12 | auth, err := EncodedAuth(imageName) 13 | if err != nil { 14 | return types.ImagePullOptions{}, err 15 | } 16 | 17 | if auth == "" { 18 | return types.ImagePullOptions{}, nil 19 | } 20 | 21 | return types.ImagePullOptions{ 22 | RegistryAuth: auth, 23 | PrivilegeFunc: func() (string, error) { return "", nil }, 24 | }, nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/docker/trust.go: -------------------------------------------------------------------------------- 1 | /* 2 | credit: https://github.com/containrrr/watchtower 3 | */ 4 | package docker 5 | 6 | import ( 7 | "encoding/base64" 8 | "encoding/json" 9 | "errors" 10 | "os" 11 | "strings" 12 | 13 | cliconfig "github.com/docker/cli/cli/config" 14 | "github.com/docker/cli/cli/config/configfile" 15 | "github.com/docker/cli/cli/config/credentials" 16 | "github.com/docker/cli/cli/config/types" 17 | "github.com/docker/distribution/reference" 18 | ) 19 | 20 | // EncodedAuth returns an encoded auth config for the given registry 21 | // loaded from environment variables or docker config 22 | // as available in that order 23 | func EncodedAuth(ref string) (string, error) { 24 | auth, err := EncodedEnvAuth(ref) 25 | if err != nil { 26 | auth, err = EncodedConfigAuth(ref) 27 | } 28 | return auth, err 29 | } 30 | 31 | // EncodedEnvAuth returns an encoded auth config for the given registry 32 | // loaded from environment variables 33 | // Returns an error if authentication environment variables have not been set 34 | func EncodedEnvAuth(ref string) (string, error) { 35 | username := os.Getenv("REPO_USER") 36 | password := os.Getenv("REPO_PASS") 37 | if username != "" && password != "" { 38 | auth := types.AuthConfig{ 39 | Username: username, 40 | Password: password, 41 | } 42 | return EncodeAuth(auth) 43 | } 44 | return "", errors.New("registry auth environment variables (REPO_USER, REPO_PASS) not set") 45 | } 46 | 47 | // EncodedConfigAuth returns an encoded auth config for the given registry 48 | // loaded from the docker config 49 | // Returns an empty string if credentials cannot be found for the referenced server 50 | // The docker config must be mounted on the container 51 | func EncodedConfigAuth(ref string) (string, error) { 52 | server, err := ParseServerAddress(ref) 53 | if err != nil { 54 | return "", err 55 | } 56 | configDir := os.Getenv("DOCKER_CONFIG") 57 | if configDir == "" { 58 | configDir = "/" 59 | } 60 | configFile, err := cliconfig.Load(configDir) 61 | if err != nil { 62 | return "", err 63 | } 64 | credStore := CredentialsStore(*configFile) 65 | auth, _ := credStore.Get(server) // returns (types.AuthConfig{}) if server not in credStore 66 | 67 | if auth == (types.AuthConfig{}) { 68 | return "", nil 69 | } 70 | return EncodeAuth(auth) 71 | } 72 | 73 | // ParseServerAddress extracts the server part from a container image ref 74 | func ParseServerAddress(ref string) (string, error) { 75 | parsedRef, err := reference.Parse(ref) 76 | if err != nil { 77 | return ref, err 78 | } 79 | 80 | parts := strings.Split(parsedRef.String(), "/") 81 | return parts[0], nil 82 | } 83 | 84 | // CredentialsStore returns a new credentials store based 85 | // on the settings provided in the configuration file. 86 | func CredentialsStore(configFile configfile.ConfigFile) credentials.Store { 87 | if configFile.CredentialsStore != "" { 88 | return credentials.NewNativeStore(&configFile, configFile.CredentialsStore) 89 | } 90 | return credentials.NewFileStore(&configFile) 91 | } 92 | 93 | // EncodeAuth Base64 encode an AuthConfig struct for transmission over HTTP 94 | func EncodeAuth(authConfig types.AuthConfig) (string, error) { 95 | buf, err := json.Marshal(authConfig) 96 | if err != nil { 97 | return "", err 98 | } 99 | return base64.URLEncoding.EncodeToString(buf), nil 100 | } 101 | -------------------------------------------------------------------------------- /pkg/docker/volumes.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import "strings" 4 | 5 | func GetDir(id, envName string) string { 6 | 7 | if strings.Contains(envName, "$AppID") && len(id) > 0 { 8 | return strings.ReplaceAll(envName, "$AppID", id) 9 | } 10 | return envName 11 | } 12 | -------------------------------------------------------------------------------- /pkg/docker/volumes_test.go: -------------------------------------------------------------------------------- 1 | package docker_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/docker" 8 | ) 9 | 10 | func TestGetDir(t *testing.T) { 11 | fmt.Println(docker.GetDir("", "config")) 12 | } 13 | -------------------------------------------------------------------------------- /pkg/utils/cpu.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "runtime" 4 | 5 | func GetCPUArch() string { 6 | return runtime.GOARCH 7 | } 8 | -------------------------------------------------------------------------------- /pkg/utils/downloadHelper/getter.go: -------------------------------------------------------------------------------- 1 | package downloadHelper 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/hashicorp/go-getter" 7 | ) 8 | 9 | func Download(src string, dst string) error { 10 | backgroundCtx := context.Background() 11 | client := &getter.Client{ 12 | Ctx: backgroundCtx, 13 | Src: src, 14 | Dst: dst, 15 | Mode: getter.ClientModeAny, 16 | Options: []getter.ClientOption{}, 17 | } 18 | 19 | return client.Get() 20 | } 21 | -------------------------------------------------------------------------------- /pkg/utils/downloadHelper/getter_test.go: -------------------------------------------------------------------------------- 1 | package downloadHelper 2 | 3 | import ( 4 | "testing" 5 | 6 | "go.uber.org/goleak" 7 | "gotest.tools/v3/assert" 8 | ) 9 | 10 | func TestDownload(t *testing.T) { 11 | defer goleak.VerifyNone(t, goleak.IgnoreTopFunction("go.opencensus.io/stats/view.(*worker).start")) // https://github.com/census-instrumentation/opencensus-go/issues/1191 12 | 13 | src := "https://github.com/IceWhaleTech/get/archive/refs/heads/main.zip" 14 | 15 | dst := t.TempDir() 16 | 17 | err := Download(src, dst) 18 | assert.NilError(t, err) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/utils/envHelper/env.go: -------------------------------------------------------------------------------- 1 | package envHelper 2 | 3 | import "strings" 4 | 5 | func ReplaceDefaultENV(key, tz string) string { 6 | temp := "" 7 | switch key { 8 | case "$DefaultPassword": 9 | temp = "casaos" 10 | case "$DefaultUserName": 11 | temp = "admin" 12 | 13 | case "$PUID": 14 | temp = "1000" 15 | case "$PGID": 16 | temp = "1000" 17 | case "$TZ": 18 | temp = tz 19 | } 20 | return temp 21 | } 22 | 23 | // replace env default setting 24 | func ReplaceStringDefaultENV(str string) string { 25 | return strings.ReplaceAll(strings.ReplaceAll(str, "$DefaultPassword", ReplaceDefaultENV("$DefaultPassword", "")), "$DefaultUserName", ReplaceDefaultENV("$DefaultUserName", "")) 26 | } 27 | -------------------------------------------------------------------------------- /route/v1.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/config" 9 | v1 "github.com/IceWhaleTech/CasaOS-AppManagement/route/v1" 10 | "github.com/IceWhaleTech/CasaOS-Common/external" 11 | "github.com/IceWhaleTech/CasaOS-Common/utils/jwt" 12 | "github.com/labstack/echo/v4" 13 | echo_middleware "github.com/labstack/echo/v4/middleware" 14 | ) 15 | 16 | const ( 17 | V1APIPath = "/v1/app_management" 18 | V1DocPath = "/v1doc" + V1APIPath 19 | ) 20 | 21 | func InitV1Router() http.Handler { 22 | e := echo.New() 23 | e.Use((echo_middleware.CORSWithConfig(echo_middleware.CORSConfig{ 24 | AllowOrigins: []string{"*"}, 25 | AllowMethods: []string{echo.POST, echo.GET, echo.OPTIONS, echo.PUT, echo.DELETE}, 26 | AllowHeaders: []string{echo.HeaderAuthorization, echo.HeaderContentLength, echo.HeaderXCSRFToken, echo.HeaderContentType, echo.HeaderAccessControlAllowOrigin, echo.HeaderAccessControlAllowHeaders, echo.HeaderAccessControlAllowMethods, echo.HeaderConnection, echo.HeaderOrigin, echo.HeaderXRequestedWith}, 27 | ExposeHeaders: []string{echo.HeaderContentLength, echo.HeaderAccessControlAllowOrigin, echo.HeaderAccessControlAllowHeaders}, 28 | MaxAge: 172800, 29 | AllowCredentials: true, 30 | }))) 31 | 32 | e.Use(echo_middleware.Gzip()) 33 | e.Use(echo_middleware.Recover()) 34 | e.Use(echo_middleware.Logger()) 35 | 36 | v1Group := e.Group("/v1") 37 | 38 | v1Group.Use(echo_middleware.JWTWithConfig(echo_middleware.JWTConfig{ 39 | Skipper: func(c echo.Context) bool { 40 | return c.RealIP() == "::1" || c.RealIP() == "127.0.0.1" 41 | }, 42 | ParseTokenFunc: func(token string, c echo.Context) (interface{}, error) { 43 | valid, claims, err := jwt.Validate(token, func() (*ecdsa.PublicKey, error) { return external.GetPublicKey(config.CommonInfo.RuntimePath) }) 44 | if err != nil || !valid { 45 | return nil, echo.ErrUnauthorized 46 | } 47 | 48 | c.Request().Header.Set("user_id", strconv.Itoa(claims.ID)) 49 | 50 | return claims, nil 51 | }, 52 | TokenLookupFuncs: []echo_middleware.ValuesExtractor{ 53 | func(c echo.Context) ([]string, error) { 54 | if len(c.Request().Header.Get(echo.HeaderAuthorization)) > 0 { 55 | return []string{c.Request().Header.Get(echo.HeaderAuthorization)}, nil 56 | } 57 | return []string{c.QueryParam("token")}, nil 58 | }, 59 | }, 60 | })) 61 | { 62 | v1ContainerGroup := v1Group.Group("/container") 63 | v1ContainerGroup.Use() 64 | { 65 | 66 | // v1ContainerGroup.GET("", v1.MyAppList) ///my/list 67 | v1ContainerGroup.GET("/usage", v1.AppUsageList) 68 | v1ContainerGroup.GET("/:id", v1.ContainerUpdateInfo) ///update/:id/info 69 | v1ContainerGroup.GET("/:id/compose", v1.ToComposeYAML) // /app/setting/:id 70 | // v1ContainerGroup.GET("/:id/logs", v1.ContainerLog) // /app/logs/:id 71 | v1ContainerGroup.GET("/networks", v1.GetDockerNetworks) // /app/install/config 72 | v1ContainerGroup.PUT("/archive/:id", v1.ArchiveContainer) // /container/archive/:id 73 | 74 | // v1ContainerGroup.GET("/:id/state", v1.GetContainerState) // app/state/:id ?state=install_progress 75 | // there are problems, temporarily do not deal with 76 | v1ContainerGroup.GET("/:id/terminal", v1.DockerTerminal) // app/terminal/:id 77 | // v1ContainerGroup.POST("", v1.InstallApp) // app/install 78 | 79 | v1ContainerGroup.PUT("/:id", v1.UpdateSetting) ///update/:id/setting 80 | 81 | // v1ContainerGroup.PUT("/:id/state", v1.ChangAppState) // /app/state/:id 82 | v1ContainerGroup.DELETE("/:id", v1.UninstallApp) // app/uninstall/:id 83 | 84 | // v1ContainerGroup.GET("/info", v1.GetDockerDaemonConfiguration) 85 | // v1ContainerGroup.PUT("/info", v1.PutDockerDaemonConfiguration) 86 | } 87 | } 88 | 89 | return e 90 | } 91 | 92 | func InitV1DocRouter(docHTML string, docYAML string) http.Handler { 93 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 94 | if r.URL.Path == V1DocPath { 95 | if _, err := w.Write([]byte(docHTML)); err != nil { 96 | w.WriteHeader(http.StatusInternalServerError) 97 | } 98 | return 99 | } 100 | 101 | if r.URL.Path == V1DocPath+"/openapi_v1.yaml" { 102 | if _, err := w.Write([]byte(docYAML)); err != nil { 103 | w.WriteHeader(http.StatusInternalServerError) 104 | } 105 | } 106 | }) 107 | } 108 | -------------------------------------------------------------------------------- /route/v1/route.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "gopkg.in/yaml.v3" 6 | ) 7 | 8 | func YAML(ctx echo.Context, code int, i interface{}) error { 9 | ctx.Response().WriteHeader(code) 10 | ctx.Response().Header().Set(echo.HeaderContentType, "text/yaml") 11 | 12 | return yaml.NewEncoder(ctx.Response()).Encode(i) 13 | } -------------------------------------------------------------------------------- /route/v2.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "net/http" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" 11 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/config" 12 | 13 | v2Route "github.com/IceWhaleTech/CasaOS-AppManagement/route/v2" 14 | "github.com/IceWhaleTech/CasaOS-Common/external" 15 | "github.com/IceWhaleTech/CasaOS-Common/utils/jwt" 16 | "github.com/deepmap/oapi-codegen/pkg/middleware" 17 | "github.com/getkin/kin-openapi/openapi3" 18 | "github.com/getkin/kin-openapi/openapi3filter" 19 | "github.com/labstack/echo/v4" 20 | echo_middleware "github.com/labstack/echo/v4/middleware" 21 | ) 22 | 23 | var ( 24 | _swagger *openapi3.T 25 | 26 | V2APIPath string 27 | V2DocPath string 28 | ) 29 | 30 | func init() { 31 | swagger, err := codegen.GetSwagger() 32 | if err != nil { 33 | panic(err) 34 | } 35 | 36 | _swagger = swagger 37 | 38 | u, err := url.Parse(_swagger.Servers[0].URL) 39 | if err != nil { 40 | panic(err) 41 | } 42 | 43 | V2APIPath = strings.TrimRight(u.Path, "/") 44 | V2DocPath = "/doc" + V2APIPath 45 | } 46 | 47 | func InitV2Router() http.Handler { 48 | appManagement := v2Route.NewAppManagement() 49 | 50 | e := echo.New() 51 | 52 | e.Use((echo_middleware.CORSWithConfig(echo_middleware.CORSConfig{ 53 | AllowOrigins: []string{"*"}, 54 | AllowMethods: []string{echo.POST, echo.GET, echo.OPTIONS, echo.PUT, echo.DELETE}, 55 | AllowHeaders: []string{echo.HeaderAuthorization, echo.HeaderContentLength, echo.HeaderXCSRFToken, echo.HeaderContentType, echo.HeaderAccessControlAllowOrigin, echo.HeaderAccessControlAllowHeaders, echo.HeaderAccessControlAllowMethods, echo.HeaderConnection, echo.HeaderOrigin, echo.HeaderXRequestedWith}, 56 | ExposeHeaders: []string{echo.HeaderContentLength, echo.HeaderAccessControlAllowOrigin, echo.HeaderAccessControlAllowHeaders}, 57 | MaxAge: 172800, 58 | AllowCredentials: true, 59 | }))) 60 | 61 | e.Use(echo_middleware.Gzip()) 62 | 63 | e.Use(echo_middleware.Logger()) 64 | 65 | e.Use(echo_middleware.JWTWithConfig(echo_middleware.JWTConfig{ 66 | Skipper: func(c echo.Context) bool { 67 | return c.RealIP() == "::1" || c.RealIP() == "127.0.0.1" 68 | }, 69 | ParseTokenFunc: func(token string, c echo.Context) (interface{}, error) { 70 | valid, claims, err := jwt.Validate(token, func() (*ecdsa.PublicKey, error) { return external.GetPublicKey(config.CommonInfo.RuntimePath) }) 71 | if err != nil || !valid { 72 | return nil, echo.ErrUnauthorized 73 | } 74 | 75 | c.Request().Header.Set("user_id", strconv.Itoa(claims.ID)) 76 | 77 | return claims, nil 78 | }, 79 | TokenLookupFuncs: []echo_middleware.ValuesExtractor{ 80 | func(c echo.Context) ([]string, error) { 81 | return []string{c.Request().Header.Get(echo.HeaderAuthorization)}, nil 82 | }, 83 | }, 84 | })) 85 | 86 | // e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { 87 | // return func(c echo.Context) error { 88 | // switch c.Request().Header.Get(echo.HeaderContentType) { 89 | // case common.MIMEApplicationYAML: // in case request contains a compose content in YAML 90 | // return middleware.OapiRequestValidatorWithOptions(_swagger, &middleware.Options{ 91 | // Options: openapi3filter.Options{ 92 | // AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, 93 | // // ExcludeRequestBody: true, 94 | // // ExcludeResponseBody: true, 95 | // }, 96 | // })(next)(c) 97 | 98 | // default: 99 | // return middleware.OapiRequestValidatorWithOptions(_swagger, &middleware.Options{ 100 | // Options: openapi3filter.Options{ 101 | // AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, 102 | // }, 103 | // })(next)(c) 104 | // } 105 | // } 106 | // }) 107 | 108 | e.Use(middleware.OapiRequestValidatorWithOptions(_swagger, &middleware.Options{ 109 | Options: openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc}, 110 | })) 111 | 112 | codegen.RegisterHandlersWithBaseURL(e, appManagement, V2APIPath) 113 | 114 | return e 115 | } 116 | 117 | func InitV2DocRouter(docHTML string, docYAML string) http.Handler { 118 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 119 | if r.URL.Path == V2DocPath { 120 | if _, err := w.Write([]byte(docHTML)); err != nil { 121 | w.WriteHeader(http.StatusInternalServerError) 122 | } 123 | return 124 | } 125 | 126 | if r.URL.Path == V2DocPath+"/openapi.yaml" { 127 | if _, err := w.Write([]byte(docYAML)); err != nil { 128 | w.WriteHeader(http.StatusInternalServerError) 129 | } 130 | } 131 | }) 132 | } 133 | -------------------------------------------------------------------------------- /route/v2/appstore_test.go: -------------------------------------------------------------------------------- 1 | package v2_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "gotest.tools/v3/assert" 7 | 8 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" 9 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 10 | v2 "github.com/IceWhaleTech/CasaOS-AppManagement/route/v2" 11 | "github.com/IceWhaleTech/CasaOS-AppManagement/service" 12 | "github.com/IceWhaleTech/CasaOS-Common/utils/logger" 13 | "github.com/compose-spec/compose-go/types" 14 | ) 15 | 16 | func TestFilterCatalogByCategory(t *testing.T) { 17 | logger.LogInitConsoleOnly() 18 | 19 | catalog := map[string]*service.ComposeApp{} 20 | 21 | filteredCatalog := v2.FilterCatalogByCategory(catalog, "test") 22 | assert.Equal(t, len(filteredCatalog), 0) 23 | 24 | catalog["test"] = &service.ComposeApp{ 25 | Extensions: map[string]interface{}{ 26 | common.ComposeExtensionNameXCasaOS: map[string]interface{}{ 27 | "category": "test", 28 | }, 29 | }, 30 | } 31 | 32 | filteredCatalog = v2.FilterCatalogByCategory(catalog, "test") 33 | assert.Equal(t, len(filteredCatalog), 1) 34 | 35 | catalog["test2"] = &service.ComposeApp{ 36 | Extensions: map[string]interface{}{ 37 | common.ComposeExtensionNameXCasaOS: map[string]interface{}{ 38 | "category": "test2", 39 | }, 40 | }, 41 | } 42 | 43 | filteredCatalog = v2.FilterCatalogByCategory(catalog, "test") 44 | assert.Equal(t, len(filteredCatalog), 1) 45 | } 46 | 47 | func TestFilterCatalogByAuthorType(t *testing.T) { 48 | logger.LogInitConsoleOnly() 49 | 50 | catalog := map[string]*service.ComposeApp{} 51 | 52 | filteredCatalog := v2.FilterCatalogByAuthorType(catalog, "test") 53 | assert.Equal(t, len(filteredCatalog), 0) 54 | 55 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, codegen.ByCasaos) 56 | assert.Equal(t, len(filteredCatalog), 0) 57 | 58 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, codegen.Official) 59 | assert.Equal(t, len(filteredCatalog), 0) 60 | 61 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, codegen.Community) 62 | assert.Equal(t, len(filteredCatalog), 0) 63 | 64 | catalog["test"] = &service.ComposeApp{ 65 | Extensions: map[string]interface{}{ 66 | common.ComposeExtensionNameXCasaOS: map[string]interface{}{ 67 | "author": common.ComposeAppAuthorCasaOSTeam, 68 | }, 69 | }, 70 | } 71 | 72 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, "test") 73 | assert.Equal(t, len(filteredCatalog), 0) 74 | 75 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, codegen.ByCasaos) 76 | assert.Equal(t, len(filteredCatalog), 1) 77 | 78 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, codegen.Official) 79 | assert.Equal(t, len(filteredCatalog), 0) 80 | 81 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, codegen.Community) 82 | assert.Equal(t, len(filteredCatalog), 0) 83 | 84 | catalog["test2"] = &service.ComposeApp{ 85 | Extensions: map[string]interface{}{ 86 | common.ComposeExtensionNameXCasaOS: map[string]interface{}{ 87 | "author": "test2", 88 | "developer": "test2", 89 | }, 90 | }, 91 | } 92 | 93 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, "test") 94 | assert.Equal(t, len(filteredCatalog), 0) 95 | 96 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, codegen.ByCasaos) 97 | assert.Equal(t, len(filteredCatalog), 1) 98 | 99 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, codegen.Official) 100 | assert.Equal(t, len(filteredCatalog), 1) 101 | 102 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, codegen.Community) 103 | assert.Equal(t, len(filteredCatalog), 0) 104 | 105 | catalog["test3"] = &service.ComposeApp{ 106 | Extensions: map[string]interface{}{ 107 | common.ComposeExtensionNameXCasaOS: map[string]interface{}{ 108 | "author": "test3", 109 | "developer": "syncthing", 110 | }, 111 | }, 112 | } 113 | 114 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, "test") 115 | assert.Equal(t, len(filteredCatalog), 0) 116 | 117 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, codegen.ByCasaos) 118 | assert.Equal(t, len(filteredCatalog), 1) 119 | 120 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, codegen.Official) 121 | assert.Equal(t, len(filteredCatalog), 1) 122 | 123 | filteredCatalog = v2.FilterCatalogByAuthorType(catalog, codegen.Community) 124 | assert.Equal(t, len(filteredCatalog), 1) 125 | } 126 | 127 | func TestFilterCatalogByAppStoreID(t *testing.T) { 128 | logger.LogInitConsoleOnly() 129 | 130 | catalog := map[string]*service.ComposeApp{} 131 | 132 | filteredCatalog := v2.FilterCatalogByAppStoreID(catalog, []string{"test"}) 133 | assert.Equal(t, len(filteredCatalog), 0) 134 | 135 | catalog["test"] = &service.ComposeApp{ 136 | Extensions: map[string]interface{}{ 137 | common.ComposeExtensionNameXCasaOS: map[string]interface{}{ 138 | "main": "test", 139 | }, 140 | }, 141 | Services: []types.ServiceConfig{ 142 | { 143 | Name: "test", 144 | Extensions: map[string]interface{}{ 145 | common.ComposeExtensionNameXCasaOS: map[string]interface{}{ 146 | "app_store_id": "test", 147 | }, 148 | }, 149 | }, 150 | }, 151 | } 152 | 153 | filteredCatalog = v2.FilterCatalogByAppStoreID(catalog, []string{"test"}) 154 | assert.Equal(t, len(filteredCatalog), 1) 155 | 156 | catalog["test2"] = &service.ComposeApp{ 157 | Extensions: map[string]interface{}{ 158 | common.ComposeExtensionNameXCasaOS: map[string]interface{}{ 159 | "main": "test2", 160 | }, 161 | }, 162 | Services: []types.ServiceConfig{ 163 | { 164 | Name: "test2", 165 | Extensions: map[string]interface{}{ 166 | common.ComposeExtensionNameXCasaOS: map[string]interface{}{ 167 | "app_store_id": "test2", 168 | }, 169 | }, 170 | }, 171 | }, 172 | } 173 | 174 | filteredCatalog = v2.FilterCatalogByAppStoreID(catalog, []string{"test"}) 175 | assert.Equal(t, len(filteredCatalog), 1) 176 | 177 | filteredCatalog = v2.FilterCatalogByAppStoreID(catalog, []string{"test", "test2"}) 178 | assert.Equal(t, len(filteredCatalog), 2) 179 | 180 | filteredCatalog = v2.FilterCatalogByAppStoreID(catalog, []string{"test", "test2", "test3"}) 181 | assert.Equal(t, len(filteredCatalog), 2) 182 | 183 | filteredCatalog = v2.FilterCatalogByAppStoreID(catalog, []string{"test1", "test2", "test3"}) 184 | assert.Equal(t, len(filteredCatalog), 1) 185 | } 186 | -------------------------------------------------------------------------------- /route/v2/convert.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" 8 | "github.com/IceWhaleTech/CasaOS-AppManagement/model" 9 | "github.com/labstack/echo/v4" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | func (a *AppManagement) Convert(ctx echo.Context, params codegen.ConvertParams) error { 14 | fileType := codegen.Appfile 15 | if params.Type != nil { 16 | fileType = *params.Type 17 | } 18 | 19 | switch fileType { 20 | 21 | case codegen.Appfile: 22 | var legacyFile model.CustomizationPostData 23 | if err := ctx.Bind(&legacyFile); err != nil { 24 | message := err.Error() 25 | return ctx.JSON(http.StatusBadRequest, codegen.ResponseBadRequest{Message: &message}) 26 | } 27 | 28 | compose := legacyFile.Compose() 29 | 30 | yaml, err := yaml.Marshal(compose) 31 | if err != nil { 32 | message := err.Error() 33 | return ctx.JSON(http.StatusBadRequest, codegen.ResponseBadRequest{Message: &message}) 34 | } 35 | 36 | return ctx.String(http.StatusOK, string(yaml)) 37 | 38 | default: 39 | message := fmt.Sprintf("unsupported file type: %s", fileType) 40 | return ctx.JSON(http.StatusBadRequest, codegen.ResponseBadRequest{Message: &message}) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /route/v2/docker.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" 8 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 9 | "github.com/IceWhaleTech/CasaOS-AppManagement/service" 10 | v1 "github.com/IceWhaleTech/CasaOS-AppManagement/service/v1" 11 | "github.com/IceWhaleTech/CasaOS-Common/utils" 12 | "github.com/IceWhaleTech/CasaOS-Common/utils/logger" 13 | "github.com/docker/docker/errdefs" 14 | "github.com/labstack/echo/v4" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | func (a *AppManagement) CheckContainerHealthByID(ctx echo.Context, id codegen.ContainerID) error { 19 | result, err := service.MyService.Docker().CheckContainerHealth(id) 20 | if err != nil { 21 | message := err.Error() 22 | return ctx.JSON(http.StatusServiceUnavailable, codegen.ResponseServiceUnavailable{Message: &message}) 23 | } 24 | 25 | if !result { 26 | return ctx.JSON(http.StatusServiceUnavailable, codegen.ResponseServiceUnavailable{}) 27 | } 28 | 29 | return ctx.JSON(http.StatusOK, codegen.ContainerHealthCheckOK{}) 30 | } 31 | 32 | func (a *AppManagement) RecreateContainerByID(ctx echo.Context, id codegen.ContainerID, params codegen.RecreateContainerByIDParams) error { 33 | // attach context key/value pairs from upstream 34 | backgroundCtx := common.WithProperties(context.Background(), PropertiesFromQueryParams(ctx)) 35 | 36 | container, err := service.MyService.Docker().DescribeContainer(backgroundCtx, id) 37 | if err != nil { 38 | 39 | message := err.Error() 40 | if _, ok := err.(errdefs.ErrNotFound); ok { 41 | return ctx.JSON(http.StatusNotFound, codegen.ResponseNotFound{Message: &message}) 42 | } 43 | 44 | return ctx.JSON(http.StatusInternalServerError, codegen.ResponseNotFound{Message: &message}) 45 | } 46 | 47 | pullLatestImage := false 48 | if params.Pull != nil { 49 | pullLatestImage = *params.Pull 50 | } 51 | 52 | force := false 53 | if params.Force != nil { 54 | force = *params.Force 55 | } 56 | 57 | eventProperties := common.PropertiesFromContext(backgroundCtx) 58 | eventProperties[common.PropertyTypeAppName.Name] = v1.AppName(container) 59 | eventProperties[common.PropertyTypeAppIcon.Name] = v1.AppIcon(container) 60 | 61 | go func() { 62 | go service.PublishEventWrapper(backgroundCtx, common.EventTypeAppUpdateBegin, nil) 63 | 64 | defer service.PublishEventWrapper(backgroundCtx, common.EventTypeAppUpdateEnd, nil) 65 | 66 | if err := service.MyService.Docker().RecreateContainer(backgroundCtx, id, pullLatestImage, force); err != nil { 67 | go service.PublishEventWrapper(backgroundCtx, common.EventTypeAppUpdateError, map[string]string{ 68 | common.PropertyTypeMessage.Name: err.Error(), 69 | }) 70 | logger.Error("error when trying to recreate container", zap.Error(err), zap.String("containerID", string(id)), zap.Bool("pull", pullLatestImage)) 71 | } 72 | }() 73 | 74 | return ctx.JSON(http.StatusOK, codegen.ContainerRecreateOK{ 75 | Message: utils.Ptr("Container is being recreated asynchronously"), 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /route/v2/global.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" 9 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 10 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/config" 11 | "github.com/IceWhaleTech/CasaOS-AppManagement/service" 12 | "github.com/IceWhaleTech/CasaOS-Common/utils" 13 | "github.com/IceWhaleTech/CasaOS-Common/utils/logger" 14 | "go.uber.org/zap" 15 | 16 | "github.com/labstack/echo/v4" 17 | ) 18 | 19 | func (a *AppManagement) GetGlobalSettings(ctx echo.Context) error { 20 | result := make([]codegen.GlobalSetting, 0) 21 | for key, value := range config.Global { 22 | result = append(result, codegen.GlobalSetting{ 23 | Key: utils.Ptr(key), 24 | Value: value, 25 | Description: utils.Ptr(key), 26 | }) 27 | } 28 | 29 | return ctx.JSON(http.StatusOK, codegen.GlobalSettingsOK{ 30 | Data: &result, 31 | }) 32 | } 33 | 34 | func (a *AppManagement) GetGlobalSetting(ctx echo.Context, key codegen.GlobalSettingKey) error { 35 | value, ok := config.Global[string(key)] 36 | if !ok { 37 | message := "the key is not exist" 38 | return ctx.JSON(http.StatusNotFound, codegen.ResponseNotFound{Message: &message}) 39 | } 40 | 41 | return ctx.JSON(http.StatusOK, codegen.GlobalSettingOK{ 42 | Data: &codegen.GlobalSetting{ 43 | Key: utils.Ptr(key), 44 | Value: value, 45 | Description: utils.Ptr(key), 46 | }, 47 | }) 48 | } 49 | 50 | func (a *AppManagement) UpdateGlobalSetting(ctx echo.Context, key codegen.GlobalSettingKey) error { 51 | var action codegen.GlobalSetting 52 | if err := ctx.Bind(&action); err != nil { 53 | message := err.Error() 54 | return ctx.JSON(http.StatusBadRequest, codegen.ResponseBadRequest{Message: &message}) 55 | } 56 | if err := updateGlobalEnv(ctx, key, action.Value); err != nil { 57 | message := err.Error() 58 | return ctx.JSON(http.StatusBadRequest, codegen.ResponseBadRequest{Message: &message}) 59 | } 60 | 61 | return ctx.JSON(http.StatusOK, codegen.GlobalSettingOK{ 62 | Data: &codegen.GlobalSetting{ 63 | Key: utils.Ptr(key), 64 | Value: action.Value, 65 | Description: utils.Ptr(key), 66 | }, 67 | }) 68 | } 69 | 70 | func updateGlobalEnv(ctx echo.Context, key string, value string) error { 71 | if key == "" { 72 | return fmt.Errorf("openai api key is required") 73 | } 74 | if err := service.MyService.AppStoreManagement().ChangeGlobal(key, value); err != nil { 75 | return err 76 | } 77 | 78 | // re up all containers to apply the new env var 79 | go func() { 80 | backgroundCtx := common.WithProperties(context.Background(), PropertiesFromQueryParams(ctx)) 81 | composeAppsWithStoreInfo, err := service.MyService.Compose().List(backgroundCtx) 82 | if err != nil { 83 | logger.Error("Failed to get composeAppsWithStoreInfo", zap.Any("error", err)) 84 | } 85 | for _, project := range composeAppsWithStoreInfo { 86 | if service, _, err := service.ApiService(); err == nil { 87 | project.UpWithCheckRequire(backgroundCtx, service) 88 | } else { 89 | logger.Error("Failed to get Api Service", zap.Any("error", err)) 90 | } 91 | } 92 | }() 93 | 94 | return nil 95 | } 96 | 97 | func deleteGlobalEnv(ctx echo.Context, key string) error { 98 | 99 | if err := service.MyService.AppStoreManagement().DeleteGlobal(key); err != nil { 100 | return err 101 | } 102 | 103 | // re up all containers to apply the new env var 104 | go func() { 105 | backgroundCtx := common.WithProperties(context.Background(), PropertiesFromQueryParams(ctx)) 106 | composeAppsWithStoreInfo, err := service.MyService.Compose().List(backgroundCtx) 107 | if err != nil { 108 | logger.Error("Failed to get composeAppsWithStoreInfo", zap.Any("error", err)) 109 | } 110 | for _, project := range composeAppsWithStoreInfo { 111 | if service, _, err := service.ApiService(); err == nil { 112 | project.UpWithCheckRequire(backgroundCtx, service) 113 | } else { 114 | logger.Error("Failed to get Api Service", zap.Any("error", err)) 115 | } 116 | } 117 | }() 118 | 119 | return nil 120 | } 121 | 122 | func (a *AppManagement) DeleteGlobalSetting(ctx echo.Context, key codegen.GlobalSettingKey) error { 123 | var action codegen.GlobalSetting 124 | if err := ctx.Bind(&action); err != nil { 125 | message := err.Error() 126 | return ctx.JSON(http.StatusBadRequest, codegen.ResponseBadRequest{Message: &message}) 127 | } 128 | 129 | if err := deleteGlobalEnv(ctx, key); err != nil { 130 | message := err.Error() 131 | return ctx.JSON(http.StatusBadRequest, codegen.ResponseBadRequest{Message: &message}) 132 | } 133 | 134 | return ctx.JSON(http.StatusOK, codegen.ResponseOK{}) 135 | } 136 | -------------------------------------------------------------------------------- /route/v2/image.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" 9 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 10 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/docker" 11 | "github.com/IceWhaleTech/CasaOS-AppManagement/service" 12 | v1 "github.com/IceWhaleTech/CasaOS-AppManagement/service/v1" 13 | "github.com/IceWhaleTech/CasaOS-Common/utils" 14 | "github.com/IceWhaleTech/CasaOS-Common/utils/logger" 15 | "github.com/labstack/echo/v4" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | func (a *AppManagement) PullImages(ctx echo.Context, params codegen.PullImagesParams) error { 20 | // attach context key/value pairs from upstream 21 | backgroundCtx := context.Background() 22 | 23 | if params.ContainerIds != nil { 24 | containerIDs := strings.Split(*params.ContainerIds, ",") 25 | for _, containerID := range containerIDs { 26 | 27 | container, err := docker.Container(backgroundCtx, containerID) 28 | if err != nil { 29 | logger.Error("get container info failed", zap.Error(err)) 30 | continue 31 | } 32 | 33 | imageName := docker.ImageName(container) 34 | if imageName == "" { 35 | continue 36 | } 37 | 38 | go func(containerID, imageName string) { 39 | backgroundCtx := common.WithProperties(backgroundCtx, PropertiesFromQueryParams(ctx)) 40 | 41 | eventProperties := common.PropertiesFromContext(backgroundCtx) 42 | eventProperties[common.PropertyTypeAppName.Name] = v1.AppName(container) 43 | eventProperties[common.PropertyTypeAppIcon.Name] = v1.AppIcon(container) 44 | 45 | _, err := service.MyService.Docker().PullLatestImage(backgroundCtx, imageName) 46 | if err != nil { 47 | logger.Error("pull new image failed", zap.Error(err), zap.String("image", imageName)) 48 | } 49 | }(containerID, imageName) 50 | } 51 | } 52 | 53 | return ctx.JSON(http.StatusOK, codegen.PullImagesOK{ 54 | Message: utils.Ptr("Images are being pulled asynchronously"), 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /route/v2/info.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" 7 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/docker" 8 | "github.com/IceWhaleTech/CasaOS-Common/utils" 9 | "github.com/labstack/echo/v4" 10 | ) 11 | 12 | func (a *AppManagement) Info(ctx echo.Context) error { 13 | architecture, err := docker.CurrentArchitecture() 14 | if err != nil { 15 | return ctx.JSON(http.StatusInternalServerError, codegen.ResponseInternalServerError{ 16 | Message: utils.Ptr(err.Error()), 17 | }) 18 | } 19 | 20 | return ctx.JSON(http.StatusOK, codegen.InfoOK{ 21 | Architecture: utils.Ptr(architecture), 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /route/v2/internal_web.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" 8 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 9 | "github.com/IceWhaleTech/CasaOS-AppManagement/model" 10 | "github.com/IceWhaleTech/CasaOS-AppManagement/service" 11 | "github.com/IceWhaleTech/CasaOS-Common/utils" 12 | "github.com/IceWhaleTech/CasaOS-Common/utils/logger" 13 | "github.com/compose-spec/compose-go/types" 14 | "github.com/docker/compose/v2/pkg/api" 15 | "github.com/labstack/echo/v4" 16 | "github.com/samber/lo" 17 | "go.uber.org/zap" 18 | ) 19 | 20 | func (a *AppManagement) GetAppGrid(ctx echo.Context) error { 21 | // v2 Apps 22 | composeAppsWithStoreInfo, err := composeAppsWithStoreInfo(ctx.Request().Context(), composeAppsWithStoreInfoOpts{ 23 | checkIsUpdateAvailable: false, 24 | }) 25 | if err != nil { 26 | message := err.Error() 27 | logger.Error("failed to list compose apps with store info", zap.Error(err)) 28 | return ctx.JSON(http.StatusInternalServerError, codegen.ResponseInternalServerError{Message: &message}) 29 | } 30 | 31 | v2AppGridItems := lo.FilterMap(lo.Values(composeAppsWithStoreInfo), func(app codegen.ComposeAppWithStoreInfo, i int) (codegen.WebAppGridItem, bool) { 32 | item, err := WebAppGridItemAdapterV2(&app) 33 | if err != nil { 34 | logger.Error("failed to adapt web app grid item", zap.Error(err), zap.String("app", app.Compose.Name)) 35 | return codegen.WebAppGridItem{}, false 36 | } 37 | 38 | return *item, true 39 | }) 40 | 41 | // v1 Apps 42 | casaOSApps, containers := service.MyService.Docker().GetContainerAppList(nil, nil, nil) 43 | 44 | v1AppGridItems := lo.Map(*casaOSApps, func(app model.MyAppList, i int) codegen.WebAppGridItem { 45 | item, err := WebAppGridItemAdapterV1(&app) 46 | if err != nil { 47 | logger.Error("failed to adapt web app grid item", zap.Error(err), zap.String("app", app.Name)) 48 | return codegen.WebAppGridItem{} 49 | } 50 | return *item 51 | }) 52 | 53 | // containers from compose apps 54 | composeAppContainers := []codegen.ContainerSummary{} 55 | for _, app := range composeAppsWithStoreInfo { 56 | composeApp := (service.ComposeApp)(*app.Compose) 57 | containerLists, err := composeApp.Containers(ctx.Request().Context()) 58 | if err != nil { 59 | logger.Error("failed to get containers for compose app", zap.Error(err), zap.String("app", composeApp.Name)) 60 | return nil 61 | } 62 | 63 | for _, containcontainerList := range containerLists { 64 | composeAppContainers = append(composeAppContainers, containcontainerList...) 65 | } 66 | } 67 | 68 | containerAppGridItems := lo.FilterMap(*containers, func(app model.MyAppList, i int) (codegen.WebAppGridItem, bool) { 69 | if lo.ContainsBy(composeAppContainers, func(container codegen.ContainerSummary) bool { return container.ID == app.ID }) { 70 | // already exists as compose app, skipping... 71 | return codegen.WebAppGridItem{}, false 72 | } 73 | 74 | // check if this is a replacement container for a compose app when applying new settings or updating. 75 | // 76 | // we need this logic so that user does not see the temporary replacement container in the UI. 77 | { 78 | container, err := service.MyService.Docker().GetContainerByName(app.Name) 79 | if err != nil { 80 | logger.Error("failed to get container by name", zap.Error(err), zap.String("container", app.Name)) 81 | return codegen.WebAppGridItem{}, false 82 | } 83 | 84 | // see recreateContainer() func from https://github.com/docker/compose/blob/v2/pkg/compose/convergence.go 85 | if replaceLabel, ok := container.Labels[api.ContainerReplaceLabel]; ok { 86 | if lo.ContainsBy( 87 | composeAppContainers, 88 | func(container codegen.ContainerSummary) bool { 89 | return container.ID == replaceLabel 90 | }, 91 | ) { 92 | // this is a replacement container for a compose app, skipping... 93 | return codegen.WebAppGridItem{}, false 94 | } 95 | } 96 | } 97 | 98 | item, err := WebAppGridItemAdapterContainer(&app) 99 | if err != nil { 100 | logger.Error("failed to adapt web app grid item", zap.Error(err), zap.String("app", app.Name)) 101 | return codegen.WebAppGridItem{}, false 102 | } 103 | return *item, true 104 | }) 105 | 106 | // merge v1 and v2 apps 107 | var appGridItems []codegen.WebAppGridItem 108 | appGridItems = append(appGridItems, v2AppGridItems...) 109 | appGridItems = append(appGridItems, v1AppGridItems...) 110 | appGridItems = append(appGridItems, containerAppGridItems...) 111 | 112 | return ctx.JSON(http.StatusOK, codegen.GetWebAppGridOK{ 113 | Message: utils.Ptr("This data is for internal use ONLY - will not be supported for public use."), 114 | Data: &appGridItems, 115 | }) 116 | } 117 | 118 | func WebAppGridItemAdapterV2(composeAppWithStoreInfo *codegen.ComposeAppWithStoreInfo) (*codegen.WebAppGridItem, error) { 119 | if composeAppWithStoreInfo == nil { 120 | return nil, fmt.Errorf("v2 compose app is nil") 121 | } 122 | 123 | // validation 124 | composeApp := (*service.ComposeApp)(composeAppWithStoreInfo.Compose) 125 | if composeApp == nil { 126 | return nil, fmt.Errorf("failed to get compose app") 127 | } 128 | 129 | item := &codegen.WebAppGridItem{ 130 | AppType: codegen.V2app, 131 | Name: &composeApp.Name, 132 | Title: lo.ToPtr(map[string]string{ 133 | common.DefaultLanguage: composeApp.Name, 134 | }), 135 | IsUncontrolled: utils.Ptr(false), 136 | } 137 | 138 | composeAppStoreInfo := composeAppWithStoreInfo.StoreInfo 139 | if composeAppStoreInfo != nil { 140 | 141 | // item properties from store info 142 | item.Hostname = composeAppStoreInfo.Hostname 143 | item.Icon = &composeAppStoreInfo.Icon 144 | item.Index = &composeAppStoreInfo.Index 145 | item.Port = &composeAppStoreInfo.PortMap 146 | item.Scheme = composeAppStoreInfo.Scheme 147 | item.Status = composeAppWithStoreInfo.Status 148 | item.StoreAppID = composeAppStoreInfo.StoreAppID 149 | item.Title = &composeAppStoreInfo.Title 150 | item.IsUncontrolled = composeAppStoreInfo.IsUncontrolled 151 | 152 | var mainApp *types.ServiceConfig 153 | for i, service := range composeApp.Services { 154 | if service.Name == *composeAppStoreInfo.Main { 155 | mainApp = &composeApp.Services[i] 156 | item.Image = &mainApp.Image // Hengxin needs this image property for some reason... 157 | } 158 | break 159 | } 160 | } 161 | 162 | // item type 163 | itemAuthorType := composeApp.AuthorType() 164 | item.AuthorType = &itemAuthorType 165 | if composeAppWithStoreInfo.IsUncontrolled == nil { 166 | item.IsUncontrolled = utils.Ptr(false) 167 | } else { 168 | item.IsUncontrolled = composeAppWithStoreInfo.IsUncontrolled 169 | } 170 | 171 | return item, nil 172 | } 173 | 174 | func WebAppGridItemAdapterV1(app *model.MyAppList) (*codegen.WebAppGridItem, error) { 175 | if app == nil { 176 | return nil, fmt.Errorf("v1 app is nil") 177 | } 178 | 179 | item := &codegen.WebAppGridItem{ 180 | AppType: codegen.V1app, 181 | Name: &app.ID, 182 | Status: &app.State, 183 | Image: &app.Image, 184 | Hostname: &app.Host, 185 | Icon: &app.Icon, 186 | Index: &app.Index, 187 | Port: &app.Port, 188 | Scheme: (*codegen.Scheme)(&app.Protocol), 189 | Title: &map[string]string{ 190 | common.DefaultLanguage: app.Name, 191 | }, 192 | IsUncontrolled: &app.IsUncontrolled, 193 | } 194 | 195 | return item, nil 196 | } 197 | 198 | func WebAppGridItemAdapterContainer(container *model.MyAppList) (*codegen.WebAppGridItem, error) { 199 | if container == nil { 200 | return nil, fmt.Errorf("container is nil") 201 | } 202 | 203 | item := &codegen.WebAppGridItem{ 204 | AppType: codegen.Container, 205 | Name: &container.ID, 206 | Status: &container.State, 207 | Image: &container.Image, 208 | Title: &map[string]string{ 209 | common.DefaultLanguage: container.Name, 210 | }, 211 | IsUncontrolled: &container.IsUncontrolled, 212 | } 213 | 214 | return item, nil 215 | } 216 | -------------------------------------------------------------------------------- /route/v2/internal_web_test.go: -------------------------------------------------------------------------------- 1 | package v2_test 2 | 3 | import ( 4 | "path/filepath" 5 | "runtime" 6 | "testing" 7 | 8 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" 9 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 10 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/docker" 11 | v2 "github.com/IceWhaleTech/CasaOS-AppManagement/route/v2" 12 | "github.com/IceWhaleTech/CasaOS-AppManagement/service" 13 | "github.com/IceWhaleTech/CasaOS-Common/utils" 14 | "github.com/IceWhaleTech/CasaOS-Common/utils/file" 15 | "go.uber.org/goleak" 16 | "gotest.tools/v3/assert" 17 | ) 18 | 19 | func TestWebAppGridItemAdapter(t *testing.T) { 20 | defer goleak.VerifyNone(t, goleak.IgnoreTopFunction("go.opencensus.io/stats/view.(*worker).start")) // https://github.com/census-instrumentation/opencensus-go/issues/1191 21 | 22 | defer func() { 23 | // workaround due to https://github.com/patrickmn/go-cache/issues/166 24 | docker.Cache = nil 25 | runtime.GC() 26 | }() 27 | 28 | storeRoot := t.TempDir() 29 | 30 | appsPath := filepath.Join(storeRoot, common.AppsDirectoryName) 31 | err := file.MkDir(appsPath) 32 | assert.NilError(t, err) 33 | 34 | // build test catalog 35 | err = file.MkDir(filepath.Join(appsPath, "test1")) 36 | assert.NilError(t, err) 37 | 38 | composeFilePath := filepath.Join(appsPath, "test1", common.ComposeYAMLFileName) 39 | 40 | err = file.WriteToFullPath([]byte(common.SampleComposeAppYAML), composeFilePath, 0o644) 41 | assert.NilError(t, err) 42 | 43 | composeApp, err := service.LoadComposeAppFromConfigFile("test1", composeFilePath) 44 | assert.NilError(t, err) 45 | 46 | storeInfo, err := composeApp.StoreInfo(true) 47 | assert.NilError(t, err) 48 | 49 | composeAppWithStoreInfo := codegen.ComposeAppWithStoreInfo{ 50 | Compose: (*codegen.ComposeApp)(composeApp), 51 | StoreInfo: storeInfo, 52 | Status: utils.Ptr("running"), 53 | } 54 | 55 | gridItem, err := v2.WebAppGridItemAdapterV2(&composeAppWithStoreInfo) 56 | assert.NilError(t, err) 57 | 58 | assert.Equal(t, *gridItem.Icon, storeInfo.Icon) 59 | assert.Equal(t, *gridItem.Image, composeApp.Services[0].Image) 60 | assert.Equal(t, gridItem.Hostname, storeInfo.Hostname) 61 | assert.Equal(t, *gridItem.Port, storeInfo.PortMap) 62 | assert.Equal(t, *gridItem.Index, storeInfo.Index) 63 | assert.Equal(t, *gridItem.Status, "running") 64 | assert.DeepEqual(t, *gridItem.Title, storeInfo.Title) 65 | assert.Equal(t, *gridItem.AuthorType, codegen.ByCasaos) 66 | assert.Equal(t, *gridItem.IsUncontrolled, false) 67 | } 68 | -------------------------------------------------------------------------------- /route/v2/route.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" 5 | "github.com/labstack/echo/v4" 6 | ) 7 | 8 | type AppManagement struct{} 9 | 10 | func NewAppManagement() codegen.ServerInterface { 11 | return &AppManagement{} 12 | } 13 | 14 | func PropertiesFromQueryParams(httpCtx echo.Context) map[string]string { 15 | properties := make(map[string]string) 16 | 17 | for k, values := range httpCtx.QueryParams() { 18 | if len(values) > 0 { 19 | properties[k] = values[0] 20 | } 21 | } 22 | 23 | return properties 24 | } 25 | 26 | func DefaultQuery(ctx echo.Context, key string, defaultValue string) string { 27 | if value := ctx.QueryParam(key); value != "" { 28 | return value 29 | } 30 | 31 | return defaultValue 32 | } 33 | -------------------------------------------------------------------------------- /service/app.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" 5 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 6 | "github.com/IceWhaleTech/CasaOS-Common/utils/logger" 7 | "github.com/compose-spec/compose-go/loader" 8 | "github.com/compose-spec/compose-go/types" 9 | ) 10 | 11 | type App types.ServiceConfig 12 | 13 | func (a *App) StoreInfo() (codegen.AppStoreInfo, error) { 14 | var storeInfo codegen.AppStoreInfo 15 | 16 | ex, ok := a.Extensions[common.ComposeExtensionNameXCasaOS] 17 | if !ok { 18 | logger.Error("extension `x-casaos` not found") 19 | // return storeInfo, ErrComposeExtensionNameXCasaOSNotFound 20 | } 21 | 22 | // add image to store info for check stable version function. 23 | storeInfo.Image = a.Image 24 | 25 | if err := loader.Transform(ex, &storeInfo); err != nil { 26 | return storeInfo, err 27 | } 28 | 29 | return storeInfo, nil 30 | } 31 | -------------------------------------------------------------------------------- /service/appstore_management_test.go: -------------------------------------------------------------------------------- 1 | package service_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "runtime" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen" 11 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 12 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/config" 13 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/docker" 14 | "github.com/IceWhaleTech/CasaOS-AppManagement/service" 15 | "github.com/IceWhaleTech/CasaOS-Common/utils/file" 16 | "github.com/IceWhaleTech/CasaOS-Common/utils/logger" 17 | "go.uber.org/goleak" 18 | "golang.org/x/net/context" 19 | "gopkg.in/yaml.v3" 20 | "gotest.tools/v3/assert" 21 | ) 22 | 23 | func TestAppStoreList(t *testing.T) { 24 | defer goleak.VerifyNone(t, goleak.IgnoreTopFunction(topFunc1), goleak.IgnoreTopFunction(pollFunc1), goleak.IgnoreTopFunction(httpFunc1)) // https://github.com/census-instrumentation/opencensus-go/issues/1191 25 | 26 | defer func() { 27 | // workaround due to https://github.com/patrickmn/go-cache/issues/166 28 | docker.Cache = nil 29 | runtime.GC() 30 | }() 31 | 32 | logger.LogInitConsoleOnly() 33 | 34 | file, err := os.CreateTemp("", "app-management.conf") 35 | assert.NilError(t, err) 36 | 37 | defer os.Remove(file.Name()) 38 | 39 | config.InitSetup(file.Name(), "") 40 | config.AppInfo.AppStorePath = t.TempDir() 41 | 42 | appStoreManagement := service.NewAppStoreManagement() 43 | 44 | appStoreList := appStoreManagement.AppStoreList() 45 | assert.Equal(t, len(appStoreList), 0) 46 | 47 | registeredAppStoreList := []string{} 48 | appStoreManagement.OnAppStoreRegister(func(appStoreURL string) error { 49 | registeredAppStoreList = append(registeredAppStoreList, appStoreURL) 50 | return nil 51 | }) 52 | 53 | unregisteredAppStoreList := []string{} 54 | appStoreManagement.OnAppStoreUnregister(func(appStoreURL string) error { 55 | unregisteredAppStoreList = append(unregisteredAppStoreList, appStoreURL) 56 | return nil 57 | }) 58 | 59 | ctx, cancel := context.WithCancel(context.Background()) 60 | defer cancel() 61 | 62 | ctx = common.WithProperties(ctx, map[string]string{}) 63 | 64 | expectAppStoreURL := strings.ToLower("https://github.com/IceWhaleTech/_appstore/archive/refs/heads/main.zip") 65 | 66 | ch := make(chan *codegen.AppStoreMetadata) 67 | 68 | err = appStoreManagement.RegisterAppStore(ctx, expectAppStoreURL, func(appStoreMetadata *codegen.AppStoreMetadata) { 69 | ch <- appStoreMetadata 70 | }) 71 | assert.NilError(t, err) 72 | 73 | appStoreMetadata := <-ch 74 | assert.Equal(t, *appStoreMetadata.URL, expectAppStoreURL) 75 | assert.Assert(t, len(registeredAppStoreList) == 1) 76 | 77 | appStoreList = appStoreManagement.AppStoreList() 78 | assert.Equal(t, len(appStoreList), 1) 79 | 80 | actualAppStoreURL := *appStoreList[0].URL 81 | assert.Equal(t, actualAppStoreURL, expectAppStoreURL) 82 | 83 | err = appStoreManagement.UnregisterAppStore(0) 84 | assert.NilError(t, err) 85 | assert.Assert(t, len(unregisteredAppStoreList) == 1) 86 | 87 | assert.DeepEqual(t, registeredAppStoreList, unregisteredAppStoreList) 88 | } 89 | 90 | func TestIsUpgradable(t *testing.T) { 91 | defer goleak.VerifyNone(t, goleak.IgnoreTopFunction(topFunc1), goleak.IgnoreTopFunction(pollFunc1), goleak.IgnoreTopFunction(httpFunc1)) // https://github.com/census-instrumentation/opencensus-go/issues/1191 92 | 93 | defer func() { 94 | // workaround due to https://github.com/patrickmn/go-cache/issues/166 95 | docker.Cache = nil 96 | runtime.GC() 97 | }() 98 | 99 | logger.LogInitConsoleOnly() 100 | 101 | appStoreManagement := service.NewAppStoreManagement() 102 | 103 | // mock store compose app 104 | storeComposeApp, err := service.NewComposeAppFromYAML([]byte(common.SampleComposeAppYAML), true, false) 105 | assert.NilError(t, err) 106 | 107 | storeComposeApp.SetStoreAppID("test") 108 | 109 | storeMainAppImage, _ := docker.ExtractImageAndTag(storeComposeApp.Services[0].Image) 110 | 111 | storeComposeAppStoreInfo, err := storeComposeApp.StoreInfo(false) 112 | assert.NilError(t, err) 113 | 114 | // mock local compose app 115 | appsPath := t.TempDir() 116 | 117 | composeFilePath := filepath.Join(appsPath, common.ComposeYAMLFileName) 118 | 119 | buf, err := yaml.Marshal(storeComposeApp) 120 | assert.NilError(t, err) 121 | 122 | err = file.WriteToFullPath(buf, composeFilePath, 0o644) 123 | assert.NilError(t, err) 124 | 125 | localComposeApp, err := service.LoadComposeAppFromConfigFile(*storeComposeAppStoreInfo.StoreAppID, composeFilePath) 126 | assert.NilError(t, err) 127 | 128 | upgradable, err := appStoreManagement.IsUpdateAvailableWith(localComposeApp, storeComposeApp) 129 | assert.NilError(t, err) 130 | assert.Assert(t, !upgradable) 131 | 132 | storeComposeApp.Services[0].Image = storeMainAppImage + ":test" 133 | 134 | upgradable, err = appStoreManagement.IsUpdateAvailableWith(localComposeApp, storeComposeApp) 135 | assert.NilError(t, err) 136 | assert.Assert(t, upgradable) 137 | } 138 | -------------------------------------------------------------------------------- /service/compose_app_test.go: -------------------------------------------------------------------------------- 1 | package service_test 2 | 3 | import ( 4 | "encoding/json" 5 | "runtime" 6 | "testing" 7 | 8 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 9 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/docker" 10 | "github.com/IceWhaleTech/CasaOS-AppManagement/service" 11 | "github.com/IceWhaleTech/CasaOS-Common/utils/logger" 12 | "go.uber.org/goleak" 13 | "gotest.tools/v3/assert" 14 | ) 15 | 16 | func TestUpdateEventPropertiesFromStoreInfo(t *testing.T) { 17 | defer goleak.VerifyNone(t, goleak.IgnoreTopFunction(topFunc1), goleak.IgnoreTopFunction(pollFunc1), goleak.IgnoreTopFunction(httpFunc1)) // https://github.com/census-instrumentation/opencensus-go/issues/1191 18 | 19 | defer func() { 20 | // workaround due to https://github.com/patrickmn/go-cache/issues/166 21 | docker.Cache = nil 22 | runtime.GC() 23 | }() 24 | 25 | logger.LogInitConsoleOnly() 26 | 27 | // mock store compose app 28 | storeComposeApp, err := service.NewComposeAppFromYAML([]byte(common.SampleComposeAppYAML), true, false) 29 | assert.NilError(t, err) 30 | 31 | storeInfo, err := storeComposeApp.StoreInfo(false) 32 | assert.NilError(t, err) 33 | 34 | eventProperties := map[string]string{} 35 | err = storeComposeApp.UpdateEventPropertiesFromStoreInfo(eventProperties) 36 | assert.NilError(t, err) 37 | 38 | // icon 39 | appIcon, ok := eventProperties[common.PropertyTypeAppIcon.Name] 40 | assert.Assert(t, ok) 41 | assert.Equal(t, appIcon, storeInfo.Icon) 42 | 43 | // title 44 | appTitle, ok := eventProperties[common.PropertyTypeAppTitle.Name] 45 | assert.Assert(t, ok) 46 | 47 | titles := map[string]string{} 48 | err = json.Unmarshal([]byte(appTitle), &titles) 49 | assert.NilError(t, err) 50 | 51 | title, ok := titles[common.DefaultLanguage] 52 | assert.Assert(t, ok) 53 | 54 | assert.Equal(t, title, storeInfo.Title[common.DefaultLanguage]) 55 | } 56 | 57 | func TestNameAndTitle(t *testing.T) { 58 | defer goleak.VerifyNone(t, goleak.IgnoreTopFunction(topFunc1), goleak.IgnoreTopFunction(pollFunc1), goleak.IgnoreTopFunction(httpFunc1)) // https://github.com/census-instrumentation/opencensus-go/issues/1191 59 | 60 | defer func() { 61 | // workaround due to https://github.com/patrickmn/go-cache/issues/166 62 | docker.Cache = nil 63 | runtime.GC() 64 | }() 65 | 66 | logger.LogInitConsoleOnly() 67 | 68 | // mock store compose app 69 | storeComposeApp, err := service.NewComposeAppFromYAML([]byte(common.SampleVanillaComposeAppYAML), true, false) 70 | assert.NilError(t, err) 71 | 72 | assert.Assert(t, len(storeComposeApp.Name) > 0) 73 | 74 | storeInfo, err := storeComposeApp.StoreInfo(false) 75 | assert.NilError(t, err) 76 | 77 | assert.Assert(t, len(storeInfo.Title) > 0) 78 | assert.Equal(t, storeComposeApp.Name, storeInfo.Title[common.DefaultLanguage]) 79 | } 80 | 81 | func TestUncontrolledApp(t *testing.T) { 82 | logger.LogInitConsoleOnly() 83 | 84 | app, err := service.NewComposeAppFromYAML([]byte(common.SampleComposeAppYAML), true, false) 85 | assert.NilError(t, err) 86 | 87 | storeInfo, err := app.StoreInfo(false) 88 | assert.NilError(t, err) 89 | // assert nil 90 | assert.Assert(t, storeInfo.IsUncontrolled == nil) 91 | 92 | err = app.SetUncontrolled(true) 93 | assert.NilError(t, err) 94 | 95 | storeInfo, err = app.StoreInfo(false) 96 | assert.NilError(t, err) 97 | assert.Assert(t, *storeInfo.IsUncontrolled) 98 | 99 | err = app.SetUncontrolled(false) 100 | assert.NilError(t, err) 101 | 102 | storeInfo, err = app.StoreInfo(false) 103 | assert.NilError(t, err) 104 | assert.Assert(t, !*storeInfo.IsUncontrolled) 105 | } 106 | -------------------------------------------------------------------------------- /service/compose_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "sync" 8 | 9 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 10 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/config" 11 | "github.com/IceWhaleTech/CasaOS-Common/utils/file" 12 | "github.com/IceWhaleTech/CasaOS-Common/utils/logger" 13 | timeutils "github.com/IceWhaleTech/CasaOS-Common/utils/time" 14 | "gopkg.in/yaml.v3" 15 | 16 | "github.com/docker/cli/cli/command" 17 | "github.com/docker/cli/cli/flags" 18 | "github.com/docker/compose/v2/pkg/api" 19 | "github.com/docker/compose/v2/pkg/compose" 20 | "github.com/docker/docker/client" 21 | 22 | "go.uber.org/zap" 23 | ) 24 | 25 | type ComposeService struct { 26 | installationInProgress sync.Map 27 | } 28 | 29 | func (s *ComposeService) PrepareWorkingDirectory(name string) (string, error) { 30 | workingDirectory := filepath.Join(config.AppInfo.AppsPath, name) 31 | 32 | if err := file.IsNotExistMkDir(workingDirectory); err != nil { 33 | logger.Error("failed to create working dir", zap.Error(err), zap.String("path", workingDirectory)) 34 | return "", err 35 | } 36 | 37 | return workingDirectory, nil 38 | } 39 | 40 | func (s *ComposeService) IsInstalling(appName string) bool { 41 | _, ok := s.installationInProgress.Load(appName) 42 | return ok 43 | } 44 | 45 | func (s *ComposeService) Install(ctx context.Context, composeApp *ComposeApp) error { 46 | // set store_app_id (by convention is the same as app name at install time if it does not exist) 47 | _, isStoreApp := composeApp.SetStoreAppID(composeApp.Name) 48 | if !isStoreApp { 49 | logger.Info("the compose app getting installed is not a store app, skipping store app id setting.") 50 | } 51 | 52 | logger.Info("installing compose app", zap.String("name", composeApp.Name)) 53 | 54 | composeYAMLInterpolated, err := yaml.Marshal(composeApp) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | workingDirectory, err := s.PrepareWorkingDirectory(composeApp.Name) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | yamlFilePath := filepath.Join(workingDirectory, common.ComposeYAMLFileName) 65 | 66 | if err := os.WriteFile(yamlFilePath, composeYAMLInterpolated, 0o600); err != nil { 67 | logger.Error("failed to save compose file", zap.Error(err), zap.String("path", yamlFilePath)) 68 | 69 | if err := file.RMDir(workingDirectory); err != nil { 70 | logger.Error("failed to cleanup working dir after failing to save compose file", zap.Error(err), zap.String("path", workingDirectory)) 71 | } 72 | return err 73 | } 74 | 75 | // load project 76 | composeApp, err = LoadComposeAppFromConfigFile(composeApp.Name, yamlFilePath) 77 | 78 | if err != nil { 79 | logger.Error("failed to install compose app", zap.Error(err), zap.String("name", composeApp.Name)) 80 | cleanup(workingDirectory) 81 | return err 82 | } 83 | 84 | // prepare for message bus events 85 | eventProperties := common.PropertiesFromContext(ctx) 86 | eventProperties[common.PropertyTypeAppName.Name] = composeApp.Name 87 | 88 | if err := composeApp.UpdateEventPropertiesFromStoreInfo(eventProperties); err != nil { 89 | logger.Info("failed to update event properties from store info", zap.Error(err), zap.String("name", composeApp.Name)) 90 | } 91 | 92 | go func(ctx context.Context) { 93 | s.installationInProgress.Store(composeApp.Name, true) 94 | defer func() { 95 | s.installationInProgress.Delete(composeApp.Name) 96 | }() 97 | 98 | go PublishEventWrapper(ctx, common.EventTypeAppInstallBegin, nil) 99 | 100 | defer PublishEventWrapper(ctx, common.EventTypeAppInstallEnd, nil) 101 | 102 | if err := composeApp.PullAndInstall(ctx); err != nil { 103 | go PublishEventWrapper(ctx, common.EventTypeAppInstallError, map[string]string{ 104 | common.PropertyTypeMessage.Name: err.Error(), 105 | }) 106 | 107 | logger.Error("failed to install compose app", zap.Error(err), zap.String("name", composeApp.Name)) 108 | } 109 | }(ctx) 110 | 111 | return nil 112 | } 113 | 114 | func (s *ComposeService) Uninstall(ctx context.Context, composeApp *ComposeApp, deleteConfigFolder bool) error { 115 | // prepare for message bus events 116 | eventProperties := common.PropertiesFromContext(ctx) 117 | eventProperties[common.PropertyTypeAppName.Name] = composeApp.Name 118 | 119 | if err := composeApp.UpdateEventPropertiesFromStoreInfo(eventProperties); err != nil { 120 | logger.Info("failed to update event properties from store info", zap.Error(err), zap.String("name", composeApp.Name)) 121 | } 122 | 123 | go func(ctx context.Context) { 124 | go PublishEventWrapper(ctx, common.EventTypeAppUninstallBegin, nil) 125 | 126 | defer PublishEventWrapper(ctx, common.EventTypeAppUninstallEnd, nil) 127 | 128 | if err := composeApp.Uninstall(ctx, deleteConfigFolder); err != nil { 129 | go PublishEventWrapper(ctx, common.EventTypeAppUninstallError, map[string]string{ 130 | common.PropertyTypeMessage.Name: err.Error(), 131 | }) 132 | 133 | logger.Error("failed to uninstall compose app", zap.Error(err), zap.String("name", composeApp.Name)) 134 | } 135 | }(ctx) 136 | 137 | return nil 138 | } 139 | 140 | func (s *ComposeService) Status(ctx context.Context, appID string) (string, error) { 141 | service, dockerClient, err := apiService() 142 | if err != nil { 143 | return "", err 144 | } 145 | defer dockerClient.Close() 146 | 147 | stackList, err := service.List(ctx, api.ListOptions{ 148 | All: true, 149 | }) 150 | if err != nil { 151 | return "", err 152 | } 153 | 154 | for _, stack := range stackList { 155 | if stack.ID == appID { 156 | return stack.Status, nil 157 | } 158 | } 159 | 160 | return "", ErrComposeAppNotFound 161 | } 162 | 163 | func (s *ComposeService) List(ctx context.Context) (map[string]*ComposeApp, error) { 164 | service, dockerClient, err := apiService() 165 | if err != nil { 166 | return nil, err 167 | } 168 | defer dockerClient.Close() 169 | 170 | stackList, err := service.List(ctx, api.ListOptions{ 171 | All: true, 172 | }) 173 | if err != nil { 174 | return nil, err 175 | } 176 | 177 | result := map[string]*ComposeApp{} 178 | 179 | for _, stack := range stackList { 180 | 181 | composeApp, err := LoadComposeAppFromConfigFile(stack.ID, stack.ConfigFiles) 182 | // load project 183 | if err != nil { 184 | logger.Error("failed to load compose file", zap.Error(err), zap.String("path", stack.ConfigFiles)) 185 | continue 186 | } 187 | 188 | result[stack.ID] = composeApp 189 | } 190 | 191 | return result, nil 192 | } 193 | 194 | func NewComposeService() *ComposeService { 195 | return &ComposeService{ 196 | installationInProgress: sync.Map{}, 197 | } 198 | } 199 | 200 | func baseInterpolationMap() map[string]string { 201 | return map[string]string{ 202 | "DefaultUserName": common.DefaultUserName, 203 | "DefaultPassword": common.DefaultPassword, 204 | "PUID": common.DefaultPUID, 205 | "PGID": common.DefaultPGID, 206 | "TZ": timeutils.GetSystemTimeZoneName(), 207 | } 208 | } 209 | 210 | func apiService() (api.Service, client.APIClient, error) { 211 | dockerCli, err := command.NewDockerCli() 212 | if err != nil { 213 | return nil, nil, err 214 | } 215 | 216 | if err := dockerCli.Initialize(&flags.ClientOptions{}); err != nil { 217 | return nil, nil, err 218 | } 219 | 220 | return compose.NewComposeService(dockerCli), dockerCli.Client(), nil 221 | } 222 | 223 | func ApiService() (api.Service, client.APIClient, error) { 224 | return apiService() 225 | } 226 | 227 | func cleanup(workDir string) { 228 | logger.Info("cleaning up working dir", zap.String("path", workDir)) 229 | if err := file.RMDir(workDir); err != nil { 230 | logger.Error("failed to cleanup working dir", zap.Error(err), zap.String("path", workDir)) 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /service/errs.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 7 | ) 8 | 9 | var ( 10 | ErrComposeAppNotFound = fmt.Errorf("compose app not found") 11 | ErrComposeAppNotMatch = fmt.Errorf("compose app not match") 12 | ErrComposeExtensionNameXCasaOSNotFound = fmt.Errorf("extension `%s` not found", common.ComposeExtensionNameXCasaOS) 13 | ErrComposeFileNotFound = fmt.Errorf("compose file not found") 14 | ErrInvalidComposeAppStatus = fmt.Errorf("invalid compose app status") 15 | ErrMainAppNotFound = fmt.Errorf("main app not found") 16 | ErrNotFoundInAppStore = fmt.Errorf("not found in app store") 17 | ErrSetStoreAppID = fmt.Errorf("failed to set store app ID") 18 | ErrStoreInfoNotFound = fmt.Errorf("store info not found") 19 | ErrMainServiceNotSpecified = fmt.Errorf("main service not been specified") 20 | ) 21 | -------------------------------------------------------------------------------- /service/image.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "strings" 8 | "time" 9 | 10 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 11 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/docker" 12 | "github.com/IceWhaleTech/CasaOS-Common/utils/logger" 13 | "github.com/docker/docker/api/types" 14 | "github.com/docker/docker/api/types/filters" 15 | client2 "github.com/docker/docker/client" 16 | "github.com/docker/docker/pkg/jsonmessage" 17 | "go.uber.org/zap" 18 | ) 19 | 20 | // 检查镜像是否存在 21 | func (ds *dockerService) IsExistImage(imageName string) bool { 22 | cli, err := client2.NewClientWithOpts(client2.FromEnv, client2.WithAPIVersionNegotiation()) 23 | if err != nil { 24 | return false 25 | } 26 | defer cli.Close() 27 | filter := filters.NewArgs() 28 | filter.Add("reference", imageName) 29 | 30 | list, err := cli.ImageList(context.Background(), types.ImageListOptions{Filters: filter}) 31 | 32 | if err == nil && len(list) > 0 { 33 | return true 34 | } 35 | 36 | return false 37 | } 38 | 39 | // 安装镜像 40 | func (ds *dockerService) PullImage(ctx context.Context, imageName string) error { 41 | go PublishEventWrapper(ctx, common.EventTypeImagePullBegin, map[string]string{ 42 | common.PropertyTypeImageName.Name: imageName, 43 | }) 44 | 45 | defer PublishEventWrapper(ctx, common.EventTypeImagePullEnd, map[string]string{ 46 | common.PropertyTypeImageName.Name: imageName, 47 | }) 48 | 49 | if err := docker.PullImage(ctx, imageName, func(out io.ReadCloser) { 50 | pullImageProgress(ctx, out, "INSTALL", 1, 1) 51 | }); err != nil { 52 | go PublishEventWrapper(ctx, common.EventTypeImagePullError, map[string]string{ 53 | common.PropertyTypeImageName.Name: imageName, 54 | common.PropertyTypeMessage.Name: err.Error(), 55 | }) 56 | } 57 | 58 | return nil 59 | } 60 | 61 | // Try to pull latest image. 62 | // 63 | // It returns `true` if the image is updated. 64 | func (ds *dockerService) PullLatestImage(ctx context.Context, imageName string) (bool, error) { 65 | isImageUpdated := false 66 | 67 | go PublishEventWrapper(ctx, common.EventTypeImagePullBegin, map[string]string{ 68 | common.PropertyTypeImageName.Name: imageName, 69 | }) 70 | 71 | defer PublishEventWrapper(ctx, common.EventTypeImagePullEnd, map[string]string{ 72 | common.PropertyTypeImageName.Name: imageName, 73 | 74 | // update image update information in the defer func below, instead of here. 75 | // this because PublishEventWrapper will retrieve the information from context and include all properties in the event. 76 | // 77 | // common.PropertyTypeImageUpdated.Name: fmt.Sprint(isImageUpdated), // <- no need to do it here. 78 | }) 79 | 80 | defer func() { 81 | // write image updated information as a property back to context, so both current func and external caller can see it 82 | properties := common.PropertiesFromContext(ctx) 83 | properties[common.PropertyTypeImageUpdated.Name] = fmt.Sprint(isImageUpdated) // <- instead, do it here. 84 | }() 85 | 86 | if strings.HasPrefix(imageName, "sha256:") { 87 | message := "container uses a pinned image, and cannot be updated" 88 | go PublishEventWrapper(ctx, common.EventTypeImagePullError, map[string]string{ 89 | common.PropertyTypeImageName.Name: imageName, 90 | common.PropertyTypeMessage.Name: message, 91 | }) 92 | 93 | return false, fmt.Errorf(message) 94 | } 95 | 96 | imageInfo1, err := docker.Image(ctx, imageName) 97 | if err != nil { 98 | go PublishEventWrapper(ctx, common.EventTypeImagePullError, map[string]string{ 99 | common.PropertyTypeImageName.Name: imageName, 100 | common.PropertyTypeMessage.Name: err.Error(), 101 | }) 102 | return false, err 103 | } 104 | 105 | if match, err := docker.CompareDigest(imageName, imageInfo1.RepoDigests); err != nil { 106 | // do nothing 107 | } else if match { 108 | return false, nil 109 | } 110 | 111 | if err = docker.PullImage(ctx, imageName, func(out io.ReadCloser) { 112 | pullImageProgress(ctx, out, "UPDATE", 1, 1) 113 | }); err != nil { 114 | go PublishEventWrapper(ctx, common.EventTypeImagePullError, map[string]string{ 115 | common.PropertyTypeImageName.Name: imageName, 116 | common.PropertyTypeMessage.Name: err.Error(), 117 | }) 118 | return false, err 119 | } 120 | 121 | imageInfo2, err := docker.Image(ctx, imageName) 122 | if err != nil { 123 | go PublishEventWrapper(ctx, common.EventTypeImagePullError, map[string]string{ 124 | common.PropertyTypeImageName.Name: imageName, 125 | common.PropertyTypeMessage.Name: err.Error(), 126 | }) 127 | return false, err 128 | } 129 | 130 | isImageUpdated = imageInfo1.ID != imageInfo2.ID 131 | return isImageUpdated, nil 132 | } 133 | 134 | // 删除镜像 135 | func (ds *dockerService) RemoveImage(name string) error { 136 | cli, err := client2.NewClientWithOpts(client2.FromEnv, client2.WithAPIVersionNegotiation()) 137 | if err != nil { 138 | return err 139 | } 140 | defer cli.Close() 141 | imageList, err := cli.ImageList(context.Background(), types.ImageListOptions{}) 142 | if err != nil { 143 | return err 144 | } 145 | 146 | imageID := "" 147 | 148 | Loop: 149 | for _, ig := range imageList { 150 | for _, i := range ig.RepoTags { 151 | if i == name { 152 | imageID = ig.ID 153 | break Loop 154 | } 155 | } 156 | } 157 | _, err = cli.ImageRemove(context.Background(), imageID, types.ImageRemoveOptions{}) 158 | return err 159 | } 160 | 161 | type StatusType string 162 | 163 | const ( 164 | Pull StatusType = "Pulling fs layer" 165 | PullComplete StatusType = "Pull complete" 166 | ) 167 | 168 | type ProgressDetail struct { 169 | Current int64 `json:"current"` 170 | Total int64 `json:"total"` 171 | } 172 | 173 | type PullOut struct { 174 | Status StatusType `json:"status"` 175 | ProgressDetail ProgressDetail `json:"progressDetail"` 176 | Id string `json:"id"` 177 | } 178 | 179 | type Throttler struct { 180 | InvokeInterval time.Duration 181 | LastInvokeTime time.Time 182 | } 183 | 184 | func NewThrottler(interval time.Duration) *Throttler { 185 | return &Throttler{ 186 | InvokeInterval: interval, 187 | } 188 | } 189 | 190 | func (t *Throttler) ThrottleFunc(f func()) { 191 | if time.Since(t.LastInvokeTime) >= t.InvokeInterval { 192 | f() 193 | t.LastInvokeTime = time.Now() 194 | } 195 | } 196 | 197 | func pullImageProgress(ctx context.Context, out io.ReadCloser, notificationType string, totalImageNum int, currentImage int) { 198 | layerNum := 0 199 | completedLayerNum := 0 200 | decoder := json.NewDecoder(out) 201 | if decoder == nil { 202 | logger.Error("failed to create json decoder") 203 | return 204 | } 205 | 206 | throttler := NewThrottler(500 * time.Millisecond) 207 | 208 | for decoder.More() { 209 | var message jsonmessage.JSONMessage 210 | if err := decoder.Decode(&message); err != nil { 211 | logger.Error("failed to decode json message", zap.Error(err)) 212 | continue 213 | } 214 | 215 | switch message.Status { 216 | // pull a new layer 217 | case string(Pull): 218 | layerNum++ 219 | // pull a layer complete 220 | case string(PullComplete): 221 | completedLayerNum++ 222 | } 223 | 224 | // layer progress 225 | completedFraction := float32(completedLayerNum) / float32(layerNum) 226 | 227 | // image progress 228 | currentImageFraction := float32(currentImage) / float32(totalImageNum) 229 | progress := completedFraction * currentImageFraction * 100 230 | 231 | // reduce the event send frequency 232 | throttler.ThrottleFunc(func() { 233 | go func(progress int) { 234 | // ensure progress is in [0, 100] 235 | if progress < 0 { 236 | progress = 0 237 | } 238 | if progress > 100 { 239 | progress = 100 240 | } 241 | 242 | PublishEventWrapper(ctx, common.EventTypeAppInstallProgress, map[string]string{ 243 | common.PropertyTypeAppProgress.Name: fmt.Sprintf("%d", progress), 244 | }) 245 | }(int(progress)) 246 | }) 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /service/service.go: -------------------------------------------------------------------------------- 1 | /*@Author: LinkLeong link@icewhale.com 2 | *@Date: 2022-07-12 09:48:56 3 | *@LastEditors: LinkLeong 4 | *@LastEditTime: 2022-09-02 22:10:05 5 | *@FilePath: /CasaOS/service/service.go 6 | *@Description: 7 | *@Website: https://www.casaos.io 8 | *Copyright (c) 2022 by icewhale, All Rights Reserved. 9 | */ 10 | package service 11 | 12 | import ( 13 | "context" 14 | "fmt" 15 | "net/http" 16 | 17 | "github.com/IceWhaleTech/CasaOS-AppManagement/codegen/message_bus" 18 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 19 | "github.com/IceWhaleTech/CasaOS-AppManagement/pkg/config" 20 | "github.com/IceWhaleTech/CasaOS-Common/external" 21 | "github.com/IceWhaleTech/CasaOS-Common/utils/logger" 22 | jsoniter "github.com/json-iterator/go" 23 | "go.uber.org/zap" 24 | ) 25 | 26 | var ( 27 | MyService Services 28 | 29 | json = jsoniter.ConfigCompatibleWithStandardLibrary 30 | ) 31 | 32 | type Services interface { 33 | AppStoreManagement() *AppStoreManagement 34 | 35 | // Git() *GitService 36 | Compose() *ComposeService 37 | Docker() DockerService 38 | Gateway() external.ManagementService 39 | Notify() external.NotifyService 40 | MessageBus() *message_bus.ClientWithResponses 41 | } 42 | 43 | func NewService(RuntimePath string) Services { 44 | gatewayManagement, err := external.NewManagementService(RuntimePath) 45 | if err != nil && len(RuntimePath) > 0 { 46 | panic(err) 47 | } 48 | 49 | return &store{ 50 | gateway: gatewayManagement, 51 | notify: external.NewNotifyService(RuntimePath), 52 | 53 | appStoreManagement: NewAppStoreManagement(), 54 | 55 | compose: NewComposeService(), 56 | docker: NewDockerService(), 57 | } 58 | } 59 | 60 | type store struct { 61 | appStoreManagement *AppStoreManagement 62 | 63 | // git *GitService 64 | compose *ComposeService 65 | docker DockerService 66 | gateway external.ManagementService 67 | notify external.NotifyService 68 | } 69 | 70 | func (c *store) Gateway() external.ManagementService { 71 | return c.gateway 72 | } 73 | 74 | func (c *store) Notify() external.NotifyService { 75 | return c.notify 76 | } 77 | 78 | func (c *store) AppStoreManagement() *AppStoreManagement { 79 | return c.appStoreManagement 80 | } 81 | 82 | func (c *store) Compose() *ComposeService { 83 | return c.compose 84 | } 85 | 86 | func (c *store) Docker() DockerService { 87 | return c.docker 88 | } 89 | 90 | func (c *store) MessageBus() *message_bus.ClientWithResponses { 91 | client, _ := message_bus.NewClientWithResponses("", func(c *message_bus.Client) error { 92 | // error will never be returned, as we always want to return a client, even with wrong address, 93 | // in order to avoid panic. 94 | // 95 | // If we don't avoid panic, message bus becomes a hard dependency, which is not what we want. 96 | 97 | messageBusAddress, err := external.GetMessageBusAddress(config.CommonInfo.RuntimePath) 98 | if err != nil { 99 | c.Server = "message bus address not found" 100 | return nil 101 | } 102 | 103 | c.Server = messageBusAddress 104 | return nil 105 | }) 106 | 107 | return client 108 | } 109 | 110 | func PublishEventWrapper(ctx context.Context, eventType message_bus.EventType, properties map[string]string) { 111 | if MyService == nil { 112 | fmt.Println("failed to publish event - messsage bus service not initialized") 113 | return 114 | } 115 | 116 | if properties == nil { 117 | properties = map[string]string{} 118 | } 119 | 120 | // merge with properties from context 121 | for k, v := range common.PropertiesFromContext(ctx) { 122 | properties[k] = v 123 | } 124 | 125 | resp, err := external.PublishEventInSocket(ctx, eventType.SourceID, eventType.Name, properties) 126 | if err != nil { 127 | logger.Error("failed to publish event", zap.Error(err)) 128 | 129 | response, err := MyService.MessageBus().PublishEventWithResponse(ctx, common.AppManagementServiceName, eventType.Name, properties) 130 | if err != nil { 131 | logger.Error("failed to publish event", zap.Error(err)) 132 | return 133 | } 134 | defer response.HTTPResponse.Body.Close() 135 | 136 | if response.StatusCode() != http.StatusOK { 137 | logger.Error("failed to publish event", zap.String("status code", response.Status())) 138 | } 139 | } else { 140 | if resp.StatusCode != http.StatusOK { 141 | logger.Error("failed to publish event", zap.String("status code", resp.Status)) 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /service/service_test.go: -------------------------------------------------------------------------------- 1 | package service_test 2 | 3 | const ( 4 | topFunc1 = "go.opencensus.io/stats/view.(*worker).start" 5 | pollFunc1 = "internal/poll.runtime_pollWait" 6 | httpFunc1 = "net/http.(*persistConn).writeLoop" 7 | ) 8 | -------------------------------------------------------------------------------- /service/utils.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/IceWhaleTech/CasaOS-Common/utils" 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | var nonAlphaNumeric = regexp.MustCompile(`[^a-z0-9]+`) 12 | 13 | func Standardize(text string) string { 14 | if text == "" { 15 | return "unknown" 16 | } 17 | 18 | result := strings.ToLower(text) 19 | 20 | // Replace any non-alphanumeric characters with a single hyphen 21 | result = nonAlphaNumeric.ReplaceAllString(result, "-") 22 | 23 | for strings.Contains(result, "--") { 24 | result = strings.Replace(result, "--", "-", -1) 25 | } 26 | 27 | // Remove any leading or trailing hyphens 28 | result = strings.Trim(result, "-") 29 | 30 | return result 31 | } 32 | 33 | func GenerateYAMLFromComposeApp(compose ComposeApp) ([]byte, error) { 34 | // to duplicate Specify Chars 35 | for _, service := range compose.Services { 36 | // it should duplicate all values that contains $. But for now, we only duplicate the env values 37 | for key, value := range service.Environment { 38 | if strings.ContainsAny(*value, "$") { 39 | service.Environment[key] = utils.Ptr(strings.Replace(*value, "$", "$$", -1)) 40 | } 41 | } 42 | } 43 | return yaml.Marshal(compose) 44 | } 45 | -------------------------------------------------------------------------------- /service/v1/app.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "github.com/docker/docker/api/types" 8 | uuid "github.com/satori/go.uuid" 9 | "go.uber.org/zap" 10 | 11 | "github.com/IceWhaleTech/CasaOS-AppManagement/common" 12 | "github.com/IceWhaleTech/CasaOS-AppManagement/model" 13 | "github.com/IceWhaleTech/CasaOS-Common/utils/logger" 14 | ) 15 | 16 | const ( 17 | V1LabelName = "name" 18 | V1LabelIcon = "icon" 19 | ) 20 | 21 | func GetCustomizationPostData(info types.ContainerJSON) model.CustomizationPostData { 22 | var port model.PortArray 23 | 24 | for k, v := range info.HostConfig.PortBindings { 25 | temp := model.PortMap{ 26 | CommendPort: v[0].HostPort, 27 | ContainerPort: k.Port(), 28 | 29 | Protocol: strings.ToLower(k.Proto()), 30 | } 31 | port = append(port, temp) 32 | } 33 | 34 | var envs model.EnvArray 35 | 36 | showENV := info.Config.Labels["show_env"] 37 | showENVList := strings.Split(showENV, ",") 38 | showENVMap := make(map[string]string) 39 | if len(showENVList) > 0 && showENVList[0] != "" { 40 | for _, name := range showENVList { 41 | showENVMap[name] = "1" 42 | } 43 | } 44 | for _, v := range info.Config.Env { 45 | env := strings.SplitN(v, "=", 2) 46 | if len(showENVList) > 0 && info.Config.Labels["origin"] != "local" { 47 | if _, ok := showENVMap[env[0]]; ok { 48 | temp := model.Env{Name: env[0], Value: env[1]} 49 | envs = append(envs, temp) 50 | } 51 | } else { 52 | temp := model.Env{Name: env[0], Value: env[1]} 53 | envs = append(envs, temp) 54 | } 55 | } 56 | 57 | var vol model.PathArray 58 | 59 | for i := 0; i < len(info.Mounts); i++ { 60 | temp := model.PathMap{ 61 | Path: strings.ReplaceAll(info.Mounts[i].Source, "$AppID", info.Name), 62 | ContainerPath: info.Mounts[i].Destination, 63 | } 64 | vol = append(vol, temp) 65 | } 66 | var driver model.PathArray 67 | 68 | for _, v := range info.HostConfig.Resources.Devices { 69 | temp := model.PathMap{ 70 | Path: v.PathOnHost, 71 | ContainerPath: v.PathInContainer, 72 | } 73 | driver = append(driver, temp) 74 | } 75 | 76 | name := AppName(&info) 77 | if len(name) == 0 { 78 | name = strings.ReplaceAll(info.Name, "/", "") 79 | } 80 | 81 | var appStoreID uint 82 | if appStoreIDStr, ok := info.Config.Labels[common.ContainerLabelV1AppStoreID]; ok { 83 | _appStoreID, err := strconv.Atoi(appStoreIDStr) 84 | if err != nil { 85 | logger.Error("error when converting appStoreID from string to int", zap.Error(err), zap.String("appStoreIDStr", appStoreIDStr)) 86 | } 87 | 88 | if _appStoreID > 0 { 89 | appStoreID = uint(_appStoreID) 90 | } 91 | } 92 | 93 | m := model.CustomizationPostData{ 94 | AppStoreID: appStoreID, 95 | CapAdd: info.HostConfig.CapAdd, 96 | Cmd: info.Config.Cmd, 97 | ContainerName: strings.ReplaceAll(info.Name, "/", ""), 98 | CPUShares: info.HostConfig.CPUShares, 99 | CustomID: info.Config.Labels["custom_id"], 100 | Description: info.Config.Labels["desc"], 101 | Devices: driver, 102 | EnableUPNP: false, 103 | Envs: envs, 104 | Host: info.Config.Labels["host"], 105 | HostName: info.Config.Hostname, 106 | Icon: AppIcon(&info), 107 | Image: info.Config.Image, 108 | Index: info.Config.Labels["index"], 109 | Label: name, 110 | Memory: info.HostConfig.Memory >> 20, 111 | NetworkModel: string(info.HostConfig.NetworkMode), 112 | Origin: info.Config.Labels["origin"], 113 | PortMap: info.Config.Labels["web"], 114 | Ports: port, 115 | Position: false, 116 | Privileged: info.HostConfig.Privileged, 117 | Protocol: info.Config.Labels["protocol"], 118 | Restart: info.HostConfig.RestartPolicy.Name, 119 | Volumes: vol, 120 | } 121 | 122 | if len(m.Origin) == 0 { 123 | m.Origin = "local" 124 | } 125 | 126 | if len(m.CustomID) == 0 { 127 | m.CustomID = uuid.NewV4().String() 128 | } 129 | 130 | if m.Protocol == "" { 131 | m.Protocol = "http" 132 | } 133 | 134 | return m 135 | } 136 | 137 | func AppName(containerInfo *types.ContainerJSON) string { 138 | if containerInfo == nil { 139 | return "" 140 | } 141 | 142 | if name, ok := containerInfo.Config.Labels[V1LabelName]; ok { 143 | return name 144 | } 145 | 146 | return strings.TrimPrefix(containerInfo.Name, "/") 147 | } 148 | 149 | func AppIcon(containerInfo *types.ContainerJSON) string { 150 | if containerInfo == nil { 151 | return "" 152 | } 153 | 154 | return containerInfo.Config.Labels[V1LabelIcon] 155 | } 156 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "lib": [ 5 | "es2017", 6 | "DOM" 7 | ], 8 | "module": "commonjs", 9 | "target": "es6", 10 | "skipLibCheck": true, 11 | "sourceMap": false, 12 | "strict": true, 13 | "useUnknownInCatchVariables": false, 14 | "resolveJsonModule": true, 15 | "esModuleInterop": true, 16 | "outDir": "dist", 17 | "types": [ 18 | "node" 19 | ], 20 | }, 21 | "exclude": [ 22 | "node_modules" 23 | ], 24 | "main": "generate/index" 25 | } --------------------------------------------------------------------------------