├── .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 | [](https://pkg.go.dev/github.com/IceWhaleTech/CasaOS-AppManagement)
4 | [](https://goreportcard.com/report/github.com/IceWhaleTech/CasaOS-AppManagement)
5 | [](https://github.com/IceWhaleTech/CasaOS-AppManagement/actions/workflows/release.yml)
6 | [](https://codecov.io/gh/IceWhaleTech/CasaOS-AppManagement)
7 | [](https://sonarcloud.io/summary/new_code?id=IceWhaleTech_CasaOS-AppManagement)
8 | [](https://sonarcloud.io/summary/new_code?id=IceWhaleTech_CasaOS-AppManagement)
9 | [](https://sonarcloud.io/summary/new_code?id=IceWhaleTech_CasaOS-AppManagement)
10 | [](https://sonarcloud.io/summary/new_code?id=IceWhaleTech_CasaOS-AppManagement)
11 | [](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 | }
--------------------------------------------------------------------------------