├── .gcloudignore ├── .github ├── CODEOWNERS └── workflows │ ├── artifacts_arm64.yaml │ ├── artifacts_x86_64.yaml │ └── build_and_test.yaml ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── api └── v1 │ ├── instancemanager.go │ └── signalingserver.go ├── app.yaml ├── build ├── .gitignore └── debian │ └── cuttlefish_cvdremote │ ├── .gitignore │ ├── debian │ ├── changelog │ ├── control │ ├── copyright │ ├── cuttlefish-cvdremote.install │ ├── rules │ └── source │ │ └── format │ └── host │ ├── etc │ └── cvdr.toml │ └── usr │ └── bin │ └── cvdr ├── cmd ├── cloud_orchestrator │ └── main.go └── cvdr │ └── main.go ├── conf.toml ├── docs ├── cloud_orchestrator.md ├── cvdr.md ├── development.md └── resources │ ├── cvdr_cf_creation_example.png │ └── cvdr_host_creation_example.png ├── go.mod ├── go.sum ├── pkg ├── app │ ├── accounts │ │ ├── accounts.go │ │ ├── gaeusers.go │ │ ├── iap.go │ │ ├── unix.go │ │ └── usernameonly.go │ ├── app.go │ ├── app_test.go │ ├── config │ │ └── config.go │ ├── database │ │ ├── database.go │ │ ├── memorydb.go │ │ └── spanner.go │ ├── encryption │ │ ├── encryption.go │ │ ├── fake.go │ │ └── gcpkms.go │ ├── errors │ │ └── errors.go │ ├── instances │ │ ├── docker.go │ │ ├── docker_test.go │ │ ├── gce.go │ │ ├── gce_test.go │ │ ├── hostclient.go │ │ ├── instances.go │ │ └── local.go │ ├── oauth2 │ │ └── oauth2.go │ ├── secrets │ │ ├── empty.go │ │ ├── gcp.go │ │ ├── local.go │ │ └── secrets.go │ └── session │ │ └── session.go ├── cli │ ├── adbserver.go │ ├── authz │ │ └── oauth.go │ ├── cli.go │ ├── cli_test.go │ ├── config.go │ ├── config_test.go │ ├── conn.go │ ├── cvd.go │ ├── cvd_test.go │ ├── host.go │ └── testing.go └── client │ ├── client.go │ └── client_test.go ├── scripts ├── gcp │ ├── app │ │ ├── app.yaml.tmpl │ │ ├── conf.toml.tmpl │ │ ├── deploy.sh │ │ └── setup.sh │ ├── cloudrun │ │ ├── .terraform.lock.hcl │ │ ├── README.md │ │ ├── build-and-deploy.sh.tmpl │ │ ├── conf.toml.tmpl │ │ ├── main.tf │ │ └── variables.tf │ ├── create_host_image.sh │ └── create_oidc_token_service_account_key_file.sh ├── goutil ├── on-premises │ └── single-server │ │ ├── README.md │ │ ├── conf.toml │ │ └── cvdr.toml └── presubmit.sh └── web ├── intercept └── js │ └── server_connector.js └── page0 ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierrc.js ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── src ├── app │ ├── active-env-pane │ │ ├── active-env-pane.component.html │ │ ├── active-env-pane.component.scss │ │ └── active-env-pane.component.ts │ ├── api.service.spec.ts │ ├── api.service.ts │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.ts │ ├── app.module.ts │ ├── create-env-view │ │ ├── create-env-view.component.html │ │ ├── create-env-view.component.scss │ │ └── create-env-view.component.ts │ ├── create-host-view │ │ ├── create-host-view.component.html │ │ ├── create-host-view.component.scss │ │ └── create-host-view.component.ts │ ├── device-form │ │ ├── device-form.component.html │ │ ├── device-form.component.scss │ │ └── device-form.component.ts │ ├── device.service.spec.ts │ ├── device.service.ts │ ├── env-card │ │ ├── env-card.component.html │ │ ├── env-card.component.scss │ │ └── env-card.component.ts │ ├── env-form.service.spec.ts │ ├── env-form.service.ts │ ├── env-list-view │ │ ├── env-list-view.component.html │ │ ├── env-list-view.component.scss │ │ └── env-list-view.component.ts │ ├── env.service.spec.ts │ ├── env.service.ts │ ├── fetch.service.spec.ts │ ├── fetch.service.ts │ ├── host.service.spec.ts │ ├── host.service.ts │ ├── interface │ │ ├── cloud-orchestrator.dto.ts │ │ ├── component-interface.ts │ │ ├── device-interface.ts │ │ ├── env-interface.ts │ │ ├── host-interface.ts │ │ ├── host-orchestrator.dto.ts │ │ ├── result-interface.ts │ │ ├── runtime-interface.ts │ │ ├── utils.ts │ │ └── wait-interface.ts │ ├── json.utils.ts │ ├── list-runtime-view │ │ ├── list-runtime-view.component.html │ │ ├── list-runtime-view.component.scss │ │ └── list-runtime-view.component.ts │ ├── operation.service.spec.ts │ ├── operation.service.ts │ ├── refresh.service.spec.ts │ ├── refresh.service.ts │ ├── register-runtime-view │ │ ├── register-runtime-view.component.html │ │ ├── register-runtime-view.component.scss │ │ └── register-runtime-view.component.ts │ ├── runtime-card │ │ ├── runtime-card.component.html │ │ ├── runtime-card.component.scss │ │ └── runtime-card.component.ts │ ├── runtime.service.spec.ts │ ├── runtime.service.ts │ ├── safe-url.pipe.ts │ ├── settings.ts │ ├── store │ │ ├── actions.ts │ │ ├── reducers.ts │ │ ├── selectors.ts │ │ ├── state.ts │ │ └── store.ts │ └── utils.ts ├── assets │ └── .gitkeep ├── favicon.ico ├── http_interceptors │ ├── auth.interceptor.ts │ ├── cors.interceptor.spec.ts │ ├── cors.interceptor.ts │ └── index.ts ├── index.html ├── main.ts ├── mock │ ├── apis.ts │ ├── envs.ts │ └── runtimes.ts ├── requirements.txt ├── server.py ├── styles.scss ├── test_apis.py └── version.ts ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json /.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | 16 | # Binaries for programs and plugins 17 | *.exe 18 | *.exe~ 19 | *.dll 20 | *.so 21 | *.dylib 22 | # Test binary, build with `go test -c` 23 | *.test 24 | # Output of the go coverage tool, specifically when used with LiteIDE 25 | *.out 26 | 27 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # refer to https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-syntax 2 | 3 | # overall code owners for cvdr and cloud orchestrator 4 | * @jemoreira @ser-io @jmacnak @rmuthiah @adelva1984 @Databean 5 | 6 | # Page0 is owned by jeongik@'s team 7 | /web/page0/ @ikicha 8 | -------------------------------------------------------------------------------- /.github/workflows/artifacts_arm64.yaml: -------------------------------------------------------------------------------- 1 | name: Artifacts arm64 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/artifacts_arm64.yaml' 7 | - 'Dockerfile' 8 | push: 9 | branches: 10 | - "main" 11 | 12 | 13 | jobs: 14 | build-cuttlefish-cloud-orchestrator-arm64-docker-image: 15 | runs-on: arm-ubuntu-arm-22.04-4core 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@a81bbbf8298c0fa03ea29cdc473d45769f953675 # aka v2 19 | - name: Get docker image filename and tag 20 | run: | 21 | short_sha=$(echo ${{ github.sha }} | cut -c1-8) 22 | echo "image_path=cloud-orchestrator-docker-image-arm64-${short_sha}.tar" >> $GITHUB_ENV 23 | echo "image_tag=cuttlefish-cloud-orchestrator:${short_sha}" >> $GITHUB_ENV 24 | - name: Build docker image 25 | run: docker build --force-rm --no-cache -t ${{ env.image_tag }} . 26 | - name: Save docker image 27 | run: docker save --output ${{ env.image_path }} ${{ env.image_tag }} 28 | - name: Publish docker image 29 | uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 # aka v4.0.0 30 | with: 31 | name: docker-image-arm64 32 | path: ${{ env.image_path }} 33 | -------------------------------------------------------------------------------- /.github/workflows/artifacts_x86_64.yaml: -------------------------------------------------------------------------------- 1 | name: Artifacts x86_64 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - "main" 8 | 9 | jobs: 10 | build-cuttlefish-cloud-orchestrator-x86_64-docker-image: 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@a81bbbf8298c0fa03ea29cdc473d45769f953675 # aka v2 15 | - name: Get docker image filename and tag 16 | run: | 17 | short_sha=$(echo ${{ github.sha }} | cut -c1-8) 18 | echo "image_path=cloud-orchestrator-docker-image-x86_64-${short_sha}.tar" >> $GITHUB_ENV 19 | echo "image_tag=cuttlefish-cloud-orchestrator:${short_sha}" >> $GITHUB_ENV 20 | - name: Build docker image 21 | run: docker build --force-rm --no-cache -t ${{ env.image_tag }} . 22 | - name: Save docker image 23 | run: docker save --output ${{ env.image_path }} ${{ env.image_tag }} 24 | - name: Publish docker image 25 | uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 # aka v4.0.0 26 | with: 27 | name: docker-image-x86_64 28 | path: ${{ env.image_path }} 29 | -------------------------------------------------------------------------------- /.github/workflows/build_and_test.yaml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | build-test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | go-version: ['1.23.4'] 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@a81bbbf8298c0fa03ea29cdc473d45769f953675 # aka v2 14 | - name: Install dependencies 15 | uses: actions/setup-go@v3 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | - run: go version 19 | - name: Build 20 | run: go build ./... 21 | - name: Test 22 | run: go test -v -coverprofile=coverage.out ./... 23 | - name: Check format 24 | run: if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi 25 | - name: Vet 26 | run: go vet ./... 27 | - name: Staticcheck 28 | uses: dominikh/staticcheck-action@v1.3.1 29 | with: 30 | version: "latest" 31 | install-go: false 32 | 33 | build-cuttlefish-cvdremote-debian-package: 34 | runs-on: ubuntu-latest 35 | strategy: 36 | matrix: 37 | go-version: ['1.23.4'] 38 | container: 39 | image: debian@sha256:6a8bad8d20e1ca5ecbb7a314e51df6fca73fcce19af2778550671bdd1cbe7b43 # aka stable-20211011 40 | steps: 41 | - name: setup apt 42 | run: apt update -y && apt upgrade -y 43 | - name: Checkout repository 44 | uses: actions/checkout@a81bbbf8298c0fa03ea29cdc473d45769f953675 # aka v2 45 | - name: Install dependencies 46 | uses: actions/setup-go@v3 47 | with: 48 | go-version: ${{ matrix.go-version }} 49 | - name: install debuild dependencies 50 | run: apt install -y git devscripts config-package-dev debhelper-compat golang 51 | - name: Build package 52 | run: cd build/debian/cuttlefish_cvdremote && dpkg-buildpackage -i -uc -us -b 53 | - name: Install package 54 | run: dpkg -i build/debian/cuttlefish-cvdremote_*_*64.deb || apt-get install -f -y 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | SETTING_FILE 2 | /cvdr 3 | /cloud_orchestrator 4 | /scripts/gcp/cloudrun/build-and-deploy.sh 5 | 6 | tags 7 | 8 | **/.terraform/* 9 | *.tfstate 10 | *.tfstate.* 11 | *.tfvars 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution, 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-alpine as builder 2 | RUN apk add --no-cache git 3 | WORKDIR /go/src/github.com/google/cloud-android-orchestrator 4 | COPY . . 5 | RUN GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build \ 6 | -trimpath \ 7 | -o /app \ 8 | cmd/cloud_orchestrator/main.go 9 | 10 | FROM gcr.io/distroless/base 11 | COPY --from=builder /app /app 12 | ADD web/intercept /web/intercept 13 | CMD ["/app"] 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloud Android Orchestration 2 | 3 | This repo holds source for a Cuttlefish Cloud Orchestration application. The 4 | Orchestration application allows to create, delete and connect to Cuttlefish 5 | devices in the cloud. 6 | 7 | The application is developed for and tested on Google Cloud Platform, but it's 8 | designed in a way that should allow its deployment in other Cloud solutions 9 | without much trouble. 10 | 11 | ## Links for each component 12 | 13 | To know details, please refer to these documents below. 14 | 15 | - [Cloud Orchestrator](docs/cloud_orchestrator.md): A web service for managing 16 | VMs or containers for running Cuttlefish instances on top of. 17 | - [cvdr](docs/cvdr.md): CLI client of Cloud Orchestrator providing user-friendly 18 | interface. 19 | - [Cuttlefish Web Launcher](web/page0/README.md): Web client of Cloud 20 | Orchestrator providing user-friendly interface. 21 | -------------------------------------------------------------------------------- /api/v1/instancemanager.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | type CreateHostRequest struct { 4 | // [REQUIRED] 5 | HostInstance *HostInstance `json:"host_instance"` 6 | } 7 | 8 | type Zone struct { 9 | Name string `json:"name"` 10 | } 11 | 12 | type HostInstance struct { 13 | // [Output Only] Instance name. 14 | Name string `json:"name,omitempty"` 15 | // GCP specific properties. 16 | GCP *GCPInstance `json:"gcp,omitempty"` 17 | // Docker specific properties. 18 | Docker *DockerInstance `json:"docker,omitempty"` 19 | } 20 | 21 | type DockerInstance struct { 22 | // Specifies the docker image name. 23 | ImageName string `json:"image_name"` 24 | // IP address of docker instance. 25 | IPAddress string `json:"ip_address"` 26 | } 27 | 28 | type GCPInstance struct { 29 | // [REQUIRED] Specifies the machine type of the VM Instance. 30 | // Check https://cloud.google.com/compute/docs/regions-zones#available for available values. 31 | MachineType string `json:"machine_type"` 32 | // Specifies a minimum CPU platform for the VM instance. 33 | MinCPUPlatform string `json:"min_cpu_platform"` 34 | // List of accelerator configurations. 35 | AcceleratorConfigs []*AcceleratorConfig `json:"accelerator_configs,omitempty"` 36 | // Boot disk size in GB; Defaults to SourceImage disk size if unset 37 | BootDiskSizeGB int64 `json:"boot_disk_size_gb,omitempty"` 38 | } 39 | 40 | type AcceleratorConfig struct { 41 | // Number of accelerators. 42 | AcceleratorCount int64 `json:"accelerator_count,omitempty"` 43 | // Name of the accelerator type (e.g. nvidia-tesla-p100). 44 | AcceleratorType string `json:"accelerator_type,omitempty"` 45 | } 46 | 47 | type Operation struct { 48 | Name string `json:"name"` 49 | // Service-specific metadata associated with the operation. It typically 50 | // contains progress information and common metadata such as create time. 51 | Metadata any `json:"metadata,omitempty"` 52 | // If the value is `false`, it means the operation is still in progress. 53 | // If `true`, the operation is completed, and either `error` or `response` is 54 | // available. 55 | Done bool `json:"done"` 56 | } 57 | 58 | type OperationResult struct { 59 | // The error result of the operation in case of failure or cancellation. 60 | Error *Error `json:"error,omitempty"` 61 | // The expected response of the operation in case of success. If the original method returns 62 | // no data on success, such as `Delete`, this field will be empty, hence omitted. If the original 63 | // method is standard: `Get`/`Create`/`Update`, the response should be the relevant resource 64 | // encoded in JSON format. 65 | Response string `json:"response,omitempty"` 66 | } 67 | 68 | type ListZonesResponse struct { 69 | Items []*Zone `json:"items"` 70 | } 71 | 72 | type ListHostsResponse struct { 73 | Items []*HostInstance `json:"items"` 74 | // This token allows you to get the next page of results for list requests. 75 | // If the number of results is larger than maxResults, use the `nextPageToken` 76 | // as a value for the query parameter `pageToken` in the next list request. 77 | // Subsequent list requests will have their own `nextPageToken` to continue 78 | // paging through out all the results. 79 | NextPageToken string `json:"nextPageToken,omitempty"` 80 | } 81 | 82 | // To be separated in to new file if the config needs to contain intormation other than instance manager 83 | type Config struct { 84 | InstanceManagerType string `json:"instance_manager_type"` 85 | } 86 | -------------------------------------------------------------------------------- /api/v1/signalingserver.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | type Error struct { 4 | Code int `json:"code"` 5 | ErrorMsg string `json:"error"` 6 | } 7 | 8 | type NewConnMsg struct { 9 | DeviceId string `json:"device_id"` 10 | } 11 | 12 | type NewConnReply struct { 13 | ConnId string `json:"connection_id"` 14 | DeviceInfo any `json:"device_info"` 15 | } 16 | 17 | type ForwardMsg struct { 18 | Payload any `json:"payload"` 19 | } 20 | 21 | type SServerResponse struct { 22 | Response any 23 | StatusCode int 24 | } 25 | 26 | type InfraConfig struct { 27 | IceServers []IceServer `json:"ice_servers"` 28 | } 29 | 30 | type IceServer struct { 31 | URLs []string `json:"urls"` 32 | Username string `json:"username,omitempty"` 33 | Credential string `json:"credential,omitempty"` 34 | } 35 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | runtime: go119 16 | 17 | main: ./cmd/cloud_orchestrator 18 | 19 | handlers: 20 | - url: /.* 21 | script: _go_app 22 | login: required 23 | -------------------------------------------------------------------------------- /build/.gitignore: -------------------------------------------------------------------------------- 1 | *.o 2 | *.buildinfo 3 | *.build 4 | *.changes 5 | *.deb 6 | *.dsc 7 | *.log 8 | 9 | cvdremote_*.*.*.tar.xz 10 | cvdremote_*.*.*.tar.gz 11 | 12 | -------------------------------------------------------------------------------- /build/debian/cuttlefish_cvdremote/.gitignore: -------------------------------------------------------------------------------- 1 | debian/cuttlefish-cvdremote/ 2 | debian/*.substvars 3 | debian/files 4 | debian/*.debhelper 5 | debian/debhelper-build-stamp 6 | 7 | -------------------------------------------------------------------------------- /build/debian/cuttlefish_cvdremote/debian/changelog: -------------------------------------------------------------------------------- 1 | cuttlefish-cvdremote (0.2.0) unstable; urgency=medium 2 | 3 | [Jorge E. Moreira] 4 | * ADB connections 5 | * Dependency version upgrades 6 | 7 | [Sergio A. Rodriguez] 8 | * Create CVD with local image 9 | * CLI restructure 10 | * Exponential backoff 11 | * Code cleanup 12 | 13 | -- Jorge Moreira Broche Wed, 01 Mar 2023 16:22:42 -0700 14 | 15 | cuttlefish-cvdremote (0.1.0) unstable; urgency=medium 16 | 17 | * Deploys cvdremote binary and config 18 | 19 | -- Jorge Moreira Broche Fri, 06 Jan 2023 17:42:34 -0700 20 | -------------------------------------------------------------------------------- /build/debian/cuttlefish_cvdremote/debian/control: -------------------------------------------------------------------------------- 1 | Source: cuttlefish-cvdremote 2 | Maintainer: Cuttlefish Team 3 | Section: misc 4 | Priority: optional 5 | Build-Depends: config-package-dev, 6 | debhelper-compat (= 12), 7 | golang (>= 2:1.13~) | golang-1.13 8 | Standards-Version: 4.5.0 9 | 10 | Package: cuttlefish-cvdremote 11 | Architecture: any 12 | Depends: ${misc:Depends}, 13 | ${shlibs:Depends} 14 | Pre-Depends: ${misc:Pre-Depends} 15 | Description: CLI client for the Cloud Android Orchestration service. 16 | 17 | -------------------------------------------------------------------------------- /build/debian/cuttlefish_cvdremote/debian/copyright: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | 3 | Files: * 4 | Copyright: 2022, The Android Open Source Project 5 | License: Apache-2.0 6 | 7 | Files: host/* 8 | Copyright: 2023, The Android Open Source Project 9 | License: Apache-2.0 10 | 11 | License: Apache-2.0 12 | This software is Copyright (c) 2022 by Google Inc. 13 | This is free software, licensed under: 14 | The Apache License, Version 2.0, January 2004 15 | Licensed under the Apache License, Version 2.0 (the "License"); 16 | you may not use this file except in compliance with the License. 17 | You may obtain a copy of the License at 18 | http://www.apache.org/licenses/LICENSE-2.0 19 | Unless required by applicable law or agreed to in writing, software 20 | distributed under the License is distributed on an "AS IS"BASIS, 21 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 22 | See the License for the specific language governing permissions and 23 | limitations under the License. 24 | On Debian systems, the complete text of the Apache License, 25 | Version 2.0 can be found in '/usr/share/common-licenses/Apache-2.0'. 26 | -------------------------------------------------------------------------------- /build/debian/cuttlefish_cvdremote/debian/cuttlefish-cvdremote.install: -------------------------------------------------------------------------------- 1 | host/etc/cvdr.toml /etc/ 2 | host/usr/bin/cvdr /usr/bin/ 3 | ../../../cvdr /usr/libexec/ 4 | -------------------------------------------------------------------------------- /build/debian/cuttlefish_cvdremote/debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | # Start build by executing: 4 | # $ debuild --no-tgz-check -us -uc 5 | 6 | # Uncomment this line out to make installation process more chatty. 7 | # Keep it on until we know there's no outstanding problems with installation. 8 | # export DH_VERBOSE=1 9 | 10 | # There's a bug here 11 | export DEB_BUILD_MAINT_OPTIONS=hardening=-format 12 | 13 | include /usr/share/dpkg/buildflags.mk 14 | 15 | 16 | %: 17 | dh $@ --with=config-package 18 | 19 | GOUTIL := ../../../scripts/goutil 20 | SOURCE_DIR := ../../.. 21 | 22 | override_dh_auto_build: 23 | $(GOUTIL) $(SOURCE_DIR) build -v -ldflags="-w" ./cmd/cvdr 24 | 25 | override_dh_auto_clean: 26 | rm -f $(SOURCE_DIR)/cvdr 27 | dh_auto_clean 28 | -------------------------------------------------------------------------------- /build/debian/cuttlefish_cvdremote/debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /build/debian/cuttlefish_cvdremote/host/etc/cvdr.toml: -------------------------------------------------------------------------------- 1 | # Configuration for the cvdr program, the CLI client to the Cloud Android 2 | # Orchestration service. For instructions on the TOML format see https://toml.io 3 | 4 | # Default service 5 | # SystemDefaultService = "foo" 6 | 7 | # Directory where the control sockets for the CVD connections will be created and 8 | # log files will be placed. The directory path should be short enough for UNIX 9 | # sockets (limited to 108 characters). 10 | # ConnectionControlDir = "~/.cvdr/connections" 11 | 12 | # Every time a new ADB connection is open, old log files are deleted. Files not 13 | # modified for KeepLogFilesDays days are considered old. Set to -1 to keep log 14 | # files forever. 15 | # KeepLogFilesDays = 30 16 | 17 | # [Services."foo"] 18 | 19 | # This property should be set on real deployments to avoid forcing users to 20 | # specify --service_url= everytime. 21 | # The URL should be the full path to the service API without including the api 22 | # version, which will be appended by the cvdr binary. 23 | # ServiceURL = "https://cloud.android.company.com/api" 24 | 25 | # The zone where the users' host VMs will be created. The value typically 26 | # depends on the Cloud Provider being used, the example below is for the Google 27 | # Cloud Platform. 28 | # Zone = "us-central1-c" 29 | 30 | # (Optional) Proxy URL. 31 | # Proxy = "http://proxy.company.com:123456" 32 | 33 | # Source for the Build API credentials to be used on cvd create operations. 34 | # Possible values are: 35 | # - "none": Don't use Build API credentials. 36 | # - "injected": Use credentials stored in the server. 37 | # BuildAPICredentialsSource = "injected" 38 | 39 | # The host.GCP section provides default configuration values for hosts created 40 | # using the GCP Cloud Provider. Other providers will have their own 41 | # host. sections when added. 42 | # Host ={ 43 | # GCP = { 44 | # MinCPUPlatform = "" 45 | # Machine type to use for new hosts when the user doesn't specify one on the 46 | # command line. The machine type must support nested virtualization for CVDs to 47 | # be hosted on them. 48 | # MachineType = "e2-standard-4" 49 | # } 50 | # } 51 | -------------------------------------------------------------------------------- /build/debian/cuttlefish_cvdremote/host/usr/bin/cvdr: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export CVDR_SYSTEM_CONFIG_PATH=/etc/cvdr.toml 4 | export CVDR_USER_CONFIG_PATH=~/.config/cvdr/cvdr.toml 5 | 6 | cvdrbin="/usr/libexec/cvdr" 7 | 8 | if [[ "$OSTYPE" == "darwin"* ]]; then 9 | cvdrbin="/usr/local/libexec/cvdr" 10 | fi 11 | 12 | exec $cvdrbin "$@" 13 | 14 | -------------------------------------------------------------------------------- /cmd/cvdr/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "io" 21 | "os" 22 | "os/exec" 23 | "path/filepath" 24 | 25 | "github.com/google/cloud-android-orchestration/pkg/cli" 26 | "golang.org/x/term" 27 | ) 28 | 29 | const ( 30 | envVarSystemConfigPath = "CVDR_SYSTEM_CONFIG_PATH" 31 | // User config values overrieds system config values. 32 | envVarUserConfigPath = "CVDR_USER_CONFIG_PATH" 33 | ) 34 | 35 | func loadInitialConfig() (*cli.Config, error) { 36 | config := cli.BaseConfig() 37 | sysConfigSrc, userConfigSrc := "", "" 38 | if path, ok := os.LookupEnv(envVarSystemConfigPath); ok { 39 | sysConfigSrc = cli.ExpandPath(path) 40 | } 41 | if path, ok := os.LookupEnv(envVarUserConfigPath); ok { 42 | path = cli.ExpandPath(path) 43 | _, statErr := os.Stat(path) 44 | if errors.Is(statErr, os.ErrNotExist) { 45 | imported, err := importAcloudConfig(path) 46 | if err != nil { 47 | return nil, err 48 | } 49 | if !imported { 50 | // Create empty user configuration file. 51 | dir := filepath.Dir(path) 52 | if err := os.MkdirAll(dir, 0750); err != nil { 53 | return nil, fmt.Errorf("failed creating user config directory: %w", err) 54 | } 55 | f, err := os.Create(path) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed creating user config file: %w", err) 58 | } 59 | f.Close() 60 | } 61 | statErr = nil 62 | } 63 | if statErr != nil { 64 | return nil, fmt.Errorf("invalid user config file path: %w", statErr) 65 | } 66 | userConfigSrc = path 67 | } 68 | if err := cli.LoadConfig(sysConfigSrc, userConfigSrc, config); err != nil { 69 | return nil, err 70 | } 71 | return config, nil 72 | } 73 | 74 | func importAcloudConfig(dst string) (bool, error) { 75 | // Create a new user configuration file importing existing acloud configuration. 76 | acPath := cli.ExpandPath("~/.config/acloud/acloud.config") 77 | if _, err := os.Stat(acPath); err == nil { 78 | yes := true 79 | // Prompt only on terminal. 80 | if term.IsTerminal(int(os.Stdout.Fd())) { 81 | const p = "No user configuration found, would you like to generate it by importing " + 82 | "your acloud configuration?" 83 | yes, err = cli.PromptYesOrNo(os.Stdout, os.Stdin, p) 84 | if err != nil { 85 | return false, err 86 | } 87 | } 88 | if yes { 89 | if err := cli.ImportAcloudConfig(acPath, dst); err != nil { 90 | return false, fmt.Errorf("failed importing acloud config file: %w", err) 91 | } 92 | return true, nil 93 | } 94 | } 95 | return false, nil 96 | } 97 | 98 | type cmdRunner struct{} 99 | 100 | func (*cmdRunner) StartBgCommand(args ...string) ([]byte, error) { 101 | cmd := exec.Command(os.Args[0], args...) 102 | cmd.Stderr = os.Stderr 103 | cmd.Stdin = os.Stdin 104 | pipe, err := cmd.StdoutPipe() 105 | if err != nil { 106 | return nil, fmt.Errorf("failed to create pipe: %w", err) 107 | } 108 | defer pipe.Close() 109 | if err := cmd.Start(); err != nil { 110 | return nil, fmt.Errorf("unable to start command: %w", err) 111 | } 112 | defer cmd.Process.Release() 113 | output, err := io.ReadAll(pipe) 114 | if err != nil { 115 | return nil, fmt.Errorf("error reading command output: %v", err) 116 | } 117 | return output, nil 118 | } 119 | 120 | func main() { 121 | config, err := loadInitialConfig() 122 | if err != nil { 123 | fmt.Fprintln(os.Stderr, err) 124 | os.Exit(2) 125 | } 126 | 127 | opts := &cli.CommandOptions{ 128 | IOStreams: cli.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr}, 129 | Args: os.Args[1:], 130 | InitialConfig: *config, 131 | CommandRunner: &cmdRunner{}, 132 | ADBServerProxy: &cli.ADBServerProxyImpl{}, 133 | } 134 | 135 | if err := cli.NewCVDRemoteCommand(opts).Execute(); err != nil { 136 | os.Exit(1) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /conf.toml: -------------------------------------------------------------------------------- 1 | WebStaticFilesPath = "web" 2 | 3 | # List of allowed origins 4 | # e.g. "https://localhost:8080" 5 | CORSAllowedOrigins = [] 6 | 7 | [AccountManager] 8 | Type = "unix" 9 | 10 | [AccountManager.OAuth2] 11 | Provider = "Google" 12 | RedirectURL = "http://localhost:8080/oauth2callback" 13 | 14 | [SecretManager] 15 | Type = "" 16 | 17 | [SecretManager.GCP] 18 | OAuth2ClientResourceID = "" 19 | 20 | [SecretManager.UNIX] 21 | SecretFilePath = "../secrets.json" 22 | 23 | [EncryptionService] 24 | Type = "Fake" 25 | 26 | [EncryptionService.GCP_KMS] 27 | KeyName = "" 28 | 29 | [DatabaseService] 30 | Type = "InMemory" 31 | 32 | [DatabaseService.Spanner] 33 | DatabaseName = "projects//instances//databases/" 34 | 35 | [InstanceManager] 36 | Type = "unix" 37 | HostOrchestratorProtocol = "http" 38 | AllowSelfSignedHostSSLCertificate = true 39 | 40 | [InstanceManager.GCP] 41 | ProjectId = "" 42 | HostImageFamily = "" 43 | HostOrchestratorPort = 1080 44 | 45 | [InstanceManager.UNIX] 46 | HostOrchestratorPort = 2080 47 | 48 | [[WebRTC.IceServers]] 49 | URLs = ["stun:stun.l.google.com:19302"] 50 | -------------------------------------------------------------------------------- /docs/cloud_orchestrator.md: -------------------------------------------------------------------------------- 1 | # Cloud Orchestrator 2 | 3 | This page describes about `Cloud Orchestrator`. 4 | 5 | ## What's Cloud Orchestrator? 6 | 7 | Cloud Orchestrator is a web service for managing virtual machines or containers 8 | as host instances. 9 | It can create, list, and delete host instances, and provides the way to access 10 | hosts to launch Cuttlefish instances on top of the host instance via Host 11 | Orchestrator. 12 | 13 | ## Build Cloud Orchestrator 14 | 15 | Please execute the command below. 16 | ```bash 17 | git clone https://github.com/google/cloud-android-orchestration.git 18 | cd cloud-android-orchestration # Root directory of this repository 19 | docker build \ 20 | --force-rm \ 21 | --no-cache \ 22 | -t cuttlefish-cloud-orchestrator \ 23 | . 24 | ``` 25 | 26 | ## Run Cloud Orchestrator 27 | 28 | To run cloud orchestrator, prepare your config file(conf.toml) for the cloud 29 | orchestrator and please execute the command below. 30 | ```bash 31 | docker run \ 32 | -p 8080:8080 \ 33 | -e CONFIG_FILE="/conf.toml" \ 34 | -v $CLOUD_ORCHESTRATOR_CONFIG_PATH:/conf.toml \ 35 | -v /var/run/docker.sock:/var/run/docker.sock \ 36 | -t cuttlefish-cloud-orchestrator:latest 37 | ``` 38 | 39 | ## Use Cloud Orchestrator 40 | 41 | We're currently providing using Cloud Orchestrator with Docker instances as 42 | hosts. Please read 43 | [scripts/on-premises/single-server/README.md](/scripts/on-premises/single-server/README.md) 44 | to follow. 45 | 46 | -------------------------------------------------------------------------------- /docs/cvdr.md: -------------------------------------------------------------------------------- 1 | # cvdr 2 | 3 | This page describes about `cvdr` and its usage. 4 | 5 | ## What's cvdr? 6 | 7 | `cvdr` is a CLI binary tool for accessing and managing Cuttlefish instances 8 | remotely. 9 | It wraps [Cloud Orchestrator](cloud_orchestrator.md), to provide user-friendly 10 | interface. 11 | 12 | Please run `cvdr --help` for advanced functionalities of `cvdr` not described 13 | below, such as launching Cuttlefish with locally built image. 14 | 15 | ## Download cvdr 16 | 17 | Since the Debian package `cuttleifsh-cvdremote` containing `cvdr` is not 18 | registered in the official Debian repository, we need to enroll JFrog repository 19 | with commands below. 20 | ```bash 21 | sudo apt install wget 22 | wget -qO- https://artifacts.codelinaro.org/artifactory/linaro-372-googlelt-gigabyte-ampere-cuttlefish-installer/gigabyte-ampere-cuttlefish-installer/latest/debian/linaro-glt-gig-archive-bookworm.asc | sudo tee /etc/apt/trusted.gpg.d/linaro-glt-gig-archive-bookworm.asc 23 | echo "deb https://artifacts.codelinaro.org/linaro-372-googlelt-gigabyte-ampere-cuttlefish-installer/gigabyte-ampere-cuttlefish-installer/latest/debian bookworm main" | sudo tee /etc/apt/sources.list.d/linaro-glt-gig-archive-bookworm.list 24 | sudo apt update 25 | ``` 26 | 27 | Then `cuttlefish-cvdremote` is available to download via `apt install`. 28 | ```base 29 | sudo apt install cuttlefish-cvdremote 30 | cvdr --help 31 | ``` 32 | 33 | ## Configure cvdr 34 | 35 | Please check and modify the configuration file(`~/.config/cvdr/cvdr.toml`). 36 | See either 37 | [build/debian/cuttlefish_cvdremote/host/etc/cvdr.toml](/build/debian/cuttlefish_cvdremote/host/etc/cvdr.toml) 38 | or 39 | [scripts/on-premises/single-server/cvdr.toml](/scripts/on-premises/single-server/cvdr.toml) 40 | as examples of how to write a configuration file. 41 | 42 | ## Use cvdr 43 | 44 | Let's assume using the latest Cuttlefish x86_64 image enrolled in 45 | [ci.android.com](https://ci.android.com/). 46 | 47 | Please run: 48 | ```bash 49 | cvdr \ 50 | --branch=aosp-main \ 51 | --build_target=aosp_cf_x86_64_phone-trunk_staging-userdebug \ 52 | create 53 | ``` 54 | 55 | Then we expect the result like below. 56 | ``` 57 | Creating Host........................................ OK 58 | Fetching main bundle artifacts....................... OK 59 | Starting and waiting for boot complete............... OK 60 | Connecting to cvd-1.................................. OK 61 | 2e8137432a96f93558c838da5e590ec775a97e5a7bb20e66929d1a59eb337351 (http://localhost:8080/v1/zones/local/hosts/2e8137432a96f93558c838da5e590ec775a97e5a7bb20e66929d1a59eb337351/) 62 | cvd/1 63 | Status: Running 64 | ADB: 127.0.0.1:33975 65 | Displays: [720 x 1280 ( 320 )] 66 | Logs: http://localhost:8080/v1/zones/local/hosts/2e8137432a96f93558c838da5e590ec775a97e5a7bb20e66929d1a59eb337351/cvds/1/logs/ 67 | ``` 68 | If you want to validate, please refer the first provided URL in the output log 69 | and check if the page seems like below. 70 | Also, you should be able to see the device is enrolled via `adb devices`. 71 | ![cvdr_cf_creation](resources/cvdr_cf_creation_example.png) 72 | 73 | ## Manually build and run cvdr 74 | 75 | To build `cvdr` manually, please run: 76 | ```bash 77 | git clone https://github.com/google/cloud-android-orchestration.git 78 | cd cloud-android-orchestration # Root directory of git repository 79 | go build ./cmd/cvdr 80 | ``` 81 | 82 | To run `cvdr`, please run: 83 | ```bash 84 | CVDR_USER_CONFIG_PATH=/path/to/cvdr.toml ./cvdr --help 85 | ``` 86 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Building 2 | 3 | From the top directory run: 4 | 5 | ``` 6 | go build ./... && go test ./... 7 | ``` 8 | 9 | To build the the executables run: 10 | 11 | ``` 12 | go build ./cmd/ 13 | ``` 14 | 15 | Where `` is one of `cvdr` or `cloud_orchestrator` 16 | 17 | # Running 18 | 19 | When the cloud orchestrator runs it reads the conf.toml configuration file. The one provided with 20 | the code uses *fakes* and *stubs* for modules that require a production environments. The OAuth2 21 | module needs a file with the client configuration, which you can create with this command: 22 | 23 | ``` 24 | cat >../secrets.json << EOF 25 | { 26 | "client_id": "", 27 | "client_secret": "" 28 | } 29 | ``` 30 | 31 | > NOTE: The file is created outside the repository to allow a real OAuth2 client configuration to 32 | be used if desired. 33 | 34 | Then, running the cloud orchestrator is as easy as executing the `cloud_orchestrator` binary with no 35 | arguments. It will listen on port 8080 for plain HTTP requests. 36 | 37 | In development, the cloud orchestrator is configured to interact with a single host orchestrator 38 | listening on 1081. 39 | 40 | ## The host orchestrator 41 | 42 | > WARNING: The host orchestrator is developed in a different repository, so the following 43 | instructions may become obsolete without notice. 44 | 45 | 1. Get The source code 46 | ``` 47 | git clone https://github.com/google/android-cuttlefish 48 | ``` 49 | 50 | 2. Build the host orchestrator binary: 51 | ``` 52 | cd frontend/src/host_orchestrator 53 | go build . 54 | ``` 55 | 56 | 3. Run the host orchestrator: 57 | 58 | ``` 59 | HO_RUN_DIR=/tmp/host_orchestrator_runtime 60 | mkdir $HO_RUN_DIR 61 | 62 | ORCHESTRATOR_SOCKET_PATH=$HO_RUN_DIR/operator \ 63 | ORCHESTRATOR_HTTP_PORT=1081 \ 64 | ORCHESTRATOR_CVD_ARTIFACTS_DIR=$HO_RUN_DIR \ 65 | ./host_orchestrator 66 | ``` 67 | -------------------------------------------------------------------------------- /docs/resources/cvdr_cf_creation_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/cloud-android-orchestration/af32efba1ab841804c5ae265076a36d5516dd5cd/docs/resources/cvdr_cf_creation_example.png -------------------------------------------------------------------------------- /docs/resources/cvdr_host_creation_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/cloud-android-orchestration/af32efba1ab841804c5ae265076a36d5516dd5cd/docs/resources/cvdr_host_creation_example.png -------------------------------------------------------------------------------- /pkg/app/accounts/accounts.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package accounts 16 | 17 | import ( 18 | "net/http" 19 | 20 | appOAuth2 "github.com/google/cloud-android-orchestration/pkg/app/oauth2" 21 | ) 22 | 23 | type User interface { 24 | Username() string 25 | 26 | Email() string 27 | } 28 | 29 | type Manager interface { 30 | // Gets the user from the http request, typically from a cookie or another header. 31 | UserFromRequest(r *http.Request) (User, error) 32 | } 33 | 34 | type AMType string 35 | 36 | type Config struct { 37 | Type AMType 38 | OAuth2 appOAuth2.OAuth2Config 39 | } 40 | -------------------------------------------------------------------------------- /pkg/app/accounts/gaeusers.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package accounts 16 | 17 | import ( 18 | "errors" 19 | "net/http" 20 | "strings" 21 | ) 22 | 23 | const ( 24 | GAEAMType AMType = "GCP" 25 | emailHeaderKey string = "X-Appengine-User-Email" 26 | ) 27 | 28 | type GAEUsersAccountManager struct{} 29 | 30 | func NewGAEUsersAccountManager() *GAEUsersAccountManager { 31 | return &GAEUsersAccountManager{} 32 | } 33 | 34 | func (g *GAEUsersAccountManager) UserFromRequest(r *http.Request) (User, error) { 35 | email, err := emailFromRequest(r) 36 | if err != nil { 37 | return nil, err 38 | } 39 | username, err := usernameFromEmail(email) 40 | if err != nil { 41 | return nil, err 42 | } 43 | return &GAEUser{username: username, email: email}, nil 44 | } 45 | 46 | func emailFromRequest(r *http.Request) (string, error) { 47 | // These headers are guaranteed to be present and come from AppEngine. 48 | return r.Header.Get(emailHeaderKey), nil 49 | } 50 | 51 | func usernameFromEmail(email string) (string, error) { 52 | if email == "" { 53 | return "", errors.New("empty email") 54 | } 55 | return strings.SplitN(email, "@", 2)[0], nil 56 | } 57 | 58 | type GAEUser struct { 59 | username string 60 | email string 61 | } 62 | 63 | func (u *GAEUser) Username() string { return u.username } 64 | 65 | func (u *GAEUser) Email() string { return u.email } 66 | -------------------------------------------------------------------------------- /pkg/app/accounts/iap.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package accounts 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "fmt" 21 | "net/http" 22 | "os" 23 | "strings" 24 | 25 | "google.golang.org/api/idtoken" 26 | ) 27 | 28 | const ( 29 | IAPType AMType = "IAP" 30 | iapJWTHeaderKey string = "x-goog-iap-jwt-assertion" 31 | ) 32 | 33 | type IAPAccountManager struct{} 34 | 35 | func NewIAPAccountManager() *IAPAccountManager { 36 | return &IAPAccountManager{} 37 | } 38 | 39 | func (g *IAPAccountManager) UserFromRequest(r *http.Request) (User, error) { 40 | token := r.Header.Get(iapJWTHeaderKey) 41 | if token == "" { 42 | return nil, fmt.Errorf("%s header is empty", iapJWTHeaderKey) 43 | } 44 | audience, ok := os.LookupEnv("IAP_AUDIENCE") 45 | if !ok { 46 | return nil, errors.New("IAP_AUDIENCE env var not set") 47 | } 48 | // TODO: if we want to validate the audience then we need to pass it in from the config, otherwise set audience to "" 49 | payload, err := idtoken.Validate(context.TODO(), token, audience) 50 | if err != nil { 51 | return nil, fmt.Errorf("unable to parse payload from %s header: %w", iapJWTHeaderKey, err) 52 | } 53 | email := payload.Claims["email"].(string) 54 | if email == "" { 55 | return nil, errors.New("empty email claim") 56 | } 57 | username := strings.SplitN(email, "@", 2)[0] 58 | return &IAPUser{username: username, email: email}, nil 59 | } 60 | 61 | type IAPUser struct { 62 | username string 63 | email string 64 | } 65 | 66 | func (u *IAPUser) Username() string { return u.username } 67 | 68 | func (u *IAPUser) Email() string { return u.email } 69 | -------------------------------------------------------------------------------- /pkg/app/accounts/unix.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package accounts 16 | 17 | import ( 18 | "net/http" 19 | "os" 20 | ) 21 | 22 | const UnixAMType AMType = "unix" 23 | 24 | // Implements the Manager interface taking the username from the 25 | // environment and authorizing all requests 26 | type UnixAccountManager struct{} 27 | 28 | func NewUnixAccountManager() *UnixAccountManager { 29 | return &UnixAccountManager{} 30 | } 31 | 32 | func (m *UnixAccountManager) UserFromRequest(r *http.Request) (User, error) { 33 | return &UnixUser{}, nil 34 | } 35 | 36 | type UnixUser struct { 37 | username string 38 | } 39 | 40 | func (i *UnixUser) Username() string { 41 | if i.username == "" { 42 | i.username = os.Getenv("USER") 43 | } 44 | return i.username 45 | } 46 | 47 | func (i *UnixUser) Email() string { return "" } 48 | -------------------------------------------------------------------------------- /pkg/app/accounts/usernameonly.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package accounts 16 | 17 | import ( 18 | "html/template" 19 | "net/http" 20 | "strings" 21 | ) 22 | 23 | const ( 24 | UsernameOnlyAMType AMType = "username-only" 25 | unameCookie string = "accountUsername" 26 | ) 27 | 28 | // Implements the AccountManager interfaces for closed deployed cloud 29 | // orchestrators. This AccountManager first gets the account information from 30 | // HTTP cookies, and leverages the RFC 2617 HTTP Basic Authentication if the 31 | // cookie does not present. Note that only username is used for both cases. 32 | type UsernameOnlyAccountManager struct{} 33 | 34 | func NewUsernameOnlyAccountManager() *UsernameOnlyAccountManager { 35 | return &UsernameOnlyAccountManager{} 36 | } 37 | 38 | func (m *UsernameOnlyAccountManager) UserFromRequest(r *http.Request) (User, error) { 39 | // Accept putting the username in a cookie to support using a browser 40 | // to interact with CO. 41 | cookie, err := r.Cookie(unameCookie) 42 | if err == nil && cookie.Value != "" { 43 | return &UsernameOnlyUser{cookie.Value}, nil 44 | } 45 | username, _, ok := r.BasicAuth() 46 | if !ok { 47 | return nil, nil 48 | } 49 | return &UsernameOnlyUser{username}, nil 50 | } 51 | 52 | type UsernameOnlyUser struct { 53 | username string 54 | } 55 | 56 | func (u *UsernameOnlyUser) Username() string { return u.username } 57 | 58 | func (u *UsernameOnlyUser) Email() string { return "" } 59 | 60 | type LoggingData struct { 61 | Username string 62 | Error string 63 | } 64 | 65 | var loggingTemplate = template.Must(template.New("logging").Parse(` 66 | 67 | 68 | AM Logging 69 | 70 | {{if .Error}} 71 |

{{.Error}}

72 | {{end}} 73 | {{if .Username}} 74 |

UsernameOnly account manager

75 |

Welcome {{.Username}}!

76 |

You can now visit other pages.

77 | {{else}} 78 |
79 |

Logging username for UsernameOnly account manager

80 | 81 |

82 | 83 |
84 | {{end}} 85 | 86 | 87 | `)) 88 | 89 | func UsernameOnlyLoggingForm(w http.ResponseWriter, r *http.Request) error { 90 | return loggingTemplate.Execute(w, nil) 91 | } 92 | 93 | func HandleUsernameOnlyLogging(w http.ResponseWriter, r *http.Request, redirect string) error { 94 | username := r.FormValue("username") 95 | if strings.TrimSpace(username) == "" { 96 | return loggingTemplate.Execute(w, LoggingData{ 97 | Error: "Please enter a valid username", 98 | }) 99 | } 100 | http.SetCookie(w, &http.Cookie{ 101 | Name: unameCookie, 102 | Value: username, 103 | Path: "/", 104 | }) 105 | if redirect != "" { 106 | http.Redirect(w, r, redirect, http.StatusFound) 107 | return nil 108 | } 109 | return loggingTemplate.Execute(w, LoggingData{ 110 | Username: username, 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /pkg/app/config/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package config 16 | 17 | import ( 18 | "os" 19 | 20 | "github.com/google/cloud-android-orchestration/pkg/app/accounts" 21 | "github.com/google/cloud-android-orchestration/pkg/app/database" 22 | "github.com/google/cloud-android-orchestration/pkg/app/encryption" 23 | "github.com/google/cloud-android-orchestration/pkg/app/instances" 24 | "github.com/google/cloud-android-orchestration/pkg/app/secrets" 25 | 26 | apiv1 "github.com/google/cloud-android-orchestration/api/v1" 27 | toml "github.com/pelletier/go-toml" 28 | ) 29 | 30 | type Config struct { 31 | WebStaticFilesPath string 32 | CORSAllowedOrigins []string 33 | AccountManager accounts.Config 34 | SecretManager secrets.Config 35 | InstanceManager instances.Config 36 | EncryptionService encryption.Config 37 | DatabaseService database.Config 38 | WebRTC apiv1.InfraConfig 39 | } 40 | 41 | const DefaultConfFile = "conf.toml" 42 | const ConfFileEnvVar = "CONFIG_FILE" 43 | 44 | func LoadConfig() (*Config, error) { 45 | confFile := os.Getenv(ConfFileEnvVar) 46 | if confFile == "" { 47 | confFile = DefaultConfFile 48 | } 49 | file, err := os.Open(confFile) 50 | if err != nil { 51 | return nil, err 52 | } 53 | decoder := toml.NewDecoder(file) 54 | var cfg Config 55 | err = decoder.Decode(&cfg) 56 | return &cfg, err 57 | } 58 | -------------------------------------------------------------------------------- /pkg/app/database/database.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package database 16 | 17 | import ( 18 | "github.com/google/cloud-android-orchestration/pkg/app/session" 19 | ) 20 | 21 | type Service interface { 22 | // Credentials are usually stored encrypted hence the []byte type. 23 | // If no credentials are available for the given user Fetch returns nil, nil. 24 | FetchBuildAPICredentials(username string) ([]byte, error) 25 | // Store new credentials or overwrite existing ones for the given user. 26 | StoreBuildAPICredentials(username string, credentials []byte) error 27 | DeleteBuildAPICredentials(username string) error 28 | // Create or update a user session. 29 | CreateOrUpdateSession(s session.Session) error 30 | // Fetch a session. Returns nil, nil if the session doesn't exist. 31 | FetchSession(key string) (*session.Session, error) 32 | // Delete a session. Won't return error if the session doesn't exist. 33 | DeleteSession(key string) error 34 | } 35 | 36 | type Config struct { 37 | Type string 38 | Spanner *SpannerConfig 39 | } 40 | -------------------------------------------------------------------------------- /pkg/app/database/memorydb.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package database 16 | 17 | import ( 18 | "github.com/google/cloud-android-orchestration/pkg/app/session" 19 | ) 20 | 21 | const InMemoryDBType = "InMemory" 22 | 23 | // Simple in memory database to use for testing or local development. 24 | type InMemoryDBService struct { 25 | credentials map[string][]byte 26 | session session.Session 27 | } 28 | 29 | func NewInMemoryDBService() *InMemoryDBService { 30 | return &InMemoryDBService{ 31 | credentials: make(map[string][]byte), 32 | } 33 | } 34 | 35 | func (dbs *InMemoryDBService) FetchBuildAPICredentials(username string) ([]byte, error) { 36 | return dbs.credentials[username], nil 37 | } 38 | 39 | func (dbs *InMemoryDBService) StoreBuildAPICredentials(username string, credentials []byte) error { 40 | dbs.credentials[username] = credentials 41 | return nil 42 | } 43 | 44 | func (dbs *InMemoryDBService) DeleteBuildAPICredentials(username string) error { 45 | delete(dbs.credentials, username) 46 | return nil 47 | } 48 | 49 | func (dbs *InMemoryDBService) CreateOrUpdateSession(s session.Session) error { 50 | dbs.session = s 51 | return nil 52 | } 53 | 54 | func (dbs *InMemoryDBService) FetchSession(key string) (*session.Session, error) { 55 | if dbs.session.Key != key { 56 | return nil, nil 57 | } 58 | sessionCopy := dbs.session 59 | return &sessionCopy, nil 60 | } 61 | 62 | func (dbs *InMemoryDBService) DeleteSession(key string) error { 63 | if dbs.session.Key == key { 64 | dbs.session = session.Session{} 65 | } 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/app/encryption/encryption.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package encryption 16 | 17 | type Service interface { 18 | Encrypt(plaintext []byte) ([]byte, error) 19 | Decrypt(ciphertext []byte) ([]byte, error) 20 | } 21 | 22 | type Config struct { 23 | Type string 24 | GCPKMS *GCPKMSConfig 25 | } 26 | -------------------------------------------------------------------------------- /pkg/app/encryption/fake.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package encryption 16 | 17 | const FakeESType = "Fake" 18 | 19 | // A simple and very insecure implementation of an encryption service to be used for testing and 20 | // local development. 21 | type FakeEncryptionService struct{} 22 | 23 | func NewFakeEncryptionService() *FakeEncryptionService { 24 | return &FakeEncryptionService{} 25 | } 26 | 27 | func (es *FakeEncryptionService) Encrypt(plaintext []byte) ([]byte, error) { 28 | // Pretend to encrypt/decrypt messages by flipping the bits in the message. That ensures the 29 | // encrypted message is different than the original. 30 | const mask byte = 255 31 | res := make([]byte, len(plaintext)) 32 | for i := 0; i < len(plaintext); i += 1 { 33 | res[i] = plaintext[i] ^ mask 34 | } 35 | return res, nil 36 | } 37 | 38 | func (es *FakeEncryptionService) Decrypt(ciphertext []byte) ([]byte, error) { 39 | // Same procedure to decrypt 40 | return es.Encrypt(ciphertext) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/app/encryption/gcpkms.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package encryption 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | 21 | kms "cloud.google.com/go/kms/apiv1" 22 | "cloud.google.com/go/kms/apiv1/kmspb" 23 | ) 24 | 25 | const GCPKMSESType = "GCP_KMS" // GCP's KMS service. 26 | 27 | type GCPKMSConfig struct { 28 | KeyName string 29 | } 30 | 31 | type GCPKMSEncryptionService struct { 32 | keyName string 33 | } 34 | 35 | func NewGCPKMSEncryptionService(keyName string) *GCPKMSEncryptionService { 36 | return &GCPKMSEncryptionService{keyName} 37 | } 38 | 39 | func (s *GCPKMSEncryptionService) Encrypt(plaintext []byte) ([]byte, error) { 40 | ctx := context.TODO() 41 | client, err := kms.NewKeyManagementClient(ctx) 42 | if err != nil { 43 | return nil, fmt.Errorf("failed to instantiate KMS client: %w", err) 44 | } 45 | defer client.Close() 46 | req := &kmspb.EncryptRequest{ 47 | Name: s.keyName, 48 | Plaintext: plaintext, 49 | } 50 | result, err := client.Encrypt(ctx, req) 51 | if err != nil { 52 | return nil, fmt.Errorf("failed encryption request: %w", err) 53 | } 54 | return result.Ciphertext, nil 55 | } 56 | 57 | func (s *GCPKMSEncryptionService) Decrypt(ciphertext []byte) ([]byte, error) { 58 | ctx := context.TODO() 59 | client, err := kms.NewKeyManagementClient(ctx) 60 | if err != nil { 61 | return nil, fmt.Errorf("failed to instantiate KMS client: %w", err) 62 | } 63 | defer client.Close() 64 | req := &kmspb.DecryptRequest{ 65 | Name: s.keyName, 66 | Ciphertext: ciphertext, 67 | } 68 | result, err := client.Decrypt(ctx, req) 69 | if err != nil { 70 | return nil, fmt.Errorf("failed encryption request: %w", err) 71 | } 72 | return result.Plaintext, nil 73 | } 74 | -------------------------------------------------------------------------------- /pkg/app/errors/errors.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package errors 16 | 17 | import ( 18 | "net/http" 19 | 20 | apiv1 "github.com/google/cloud-android-orchestration/api/v1" 21 | ) 22 | 23 | type AppError struct { 24 | Msg string 25 | StatusCode int 26 | Err error 27 | } 28 | 29 | func (e *AppError) Error() string { 30 | if e.Err != nil { 31 | return e.Msg + ": " + e.Err.Error() 32 | } 33 | return e.Msg 34 | } 35 | func (e *AppError) Unwrap() error { 36 | return e.Err 37 | } 38 | 39 | func (e *AppError) JSONResponse() apiv1.Error { 40 | // Include only the high level error message in the error response, the 41 | // lower level errors are just for logging 42 | return apiv1.Error{ 43 | Code: e.StatusCode, 44 | ErrorMsg: e.Msg, 45 | } 46 | } 47 | 48 | func NewNotFoundError(msg string, e error) error { 49 | return &AppError{Msg: msg, StatusCode: http.StatusNotFound, Err: e} 50 | } 51 | 52 | func NewBadRequestError(msg string, e error) error { 53 | return &AppError{Msg: msg, StatusCode: http.StatusBadRequest, Err: e} 54 | } 55 | 56 | func NewMethodNotAllowedError(msg string, e error) error { 57 | return &AppError{Msg: msg, StatusCode: http.StatusMethodNotAllowed, Err: e} 58 | } 59 | 60 | func NewInternalError(msg string, e error) error { 61 | return &AppError{Msg: msg, StatusCode: http.StatusInternalServerError, Err: e} 62 | } 63 | 64 | func NewUnauthenticatedError(msg string, e error) error { 65 | // 401 Unauthorized semantically means "Unauthenticated, while authorization errors are to be 66 | // returned with 403 Forbidden according to 67 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses. 68 | return &AppError{Msg: msg, StatusCode: http.StatusUnauthorized, Err: e} 69 | } 70 | 71 | func NewForbiddenError(msg string, e error) error { 72 | return &AppError{Msg: msg, StatusCode: http.StatusForbidden, Err: e} 73 | } 74 | 75 | func NewServiceUnavailableError(msg string, e error) error { 76 | return &AppError{Msg: msg, StatusCode: http.StatusServiceUnavailable, Err: e} 77 | } 78 | -------------------------------------------------------------------------------- /pkg/app/instances/docker_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package instances 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/google/go-cmp/cmp" 21 | ) 22 | 23 | func TestEncodeOperationNameSucceeds(t *testing.T) { 24 | if diff := cmp.Diff("foo_bar", EncodeOperationName("foo", "bar")); diff != "" { 25 | t.Errorf("encoded operation name mismatch (-want +got):\n%s", diff) 26 | } 27 | } 28 | 29 | func TestDecodeOperationSucceeds(t *testing.T) { 30 | gotOpType, gotHost, err := DecodeOperationName("foo_bar") 31 | if err != nil { 32 | t.Errorf("got error while decoding operation name: %+v", err) 33 | } 34 | if diff := cmp.Diff(OPType("foo"), gotOpType); diff != "" { 35 | t.Errorf("decoded operation type mismatch (-want +got):\n%s", diff) 36 | } 37 | if diff := cmp.Diff("bar", gotHost); diff != "" { 38 | t.Errorf("decoded host mismatch (-want +got):\n%s", diff) 39 | } 40 | } 41 | 42 | func TestDecodeOperationFailsEmptyString(t *testing.T) { 43 | _, _, err := DecodeOperationName("") 44 | if err == nil { 45 | t.Errorf("expected error") 46 | } 47 | } 48 | 49 | func TestDecodeOperationFailsMissingUnderscore(t *testing.T) { 50 | _, _, err := DecodeOperationName("foobar") 51 | if err == nil { 52 | t.Errorf("expected error") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pkg/app/instances/hostclient.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package instances 16 | 17 | import ( 18 | "bytes" 19 | "crypto/tls" 20 | "encoding/json" 21 | "fmt" 22 | "log" 23 | "net/http" 24 | "net/http/httputil" 25 | "net/url" 26 | 27 | apiv1 "github.com/google/cloud-android-orchestration/api/v1" 28 | ) 29 | 30 | type NetHostClient struct { 31 | url *url.URL 32 | client *http.Client 33 | } 34 | 35 | func NewNetHostClient(url *url.URL, allowSelfSigned bool) *NetHostClient { 36 | ret := &NetHostClient{ 37 | url: url, 38 | client: http.DefaultClient, 39 | } 40 | if allowSelfSigned { 41 | // This creates a transport similar to http.DefaultTransport according to 42 | // https://pkg.go.dev/net/http#RoundTripper. The object needs to be created 43 | // instead of copied from http.DefaultTransport because it has a mutex which 44 | // could be copied in locked state and produce a copy that's unusable because 45 | // nothing will ever unlock it. 46 | defaultTransport := http.DefaultTransport.(*http.Transport) 47 | transport := &http.Transport{ 48 | Proxy: defaultTransport.Proxy, 49 | // Reusing the same dial context allows reusing connections across transport objects. 50 | DialContext: defaultTransport.DialContext, 51 | ForceAttemptHTTP2: defaultTransport.ForceAttemptHTTP2, 52 | MaxIdleConns: defaultTransport.MaxIdleConns, 53 | IdleConnTimeout: defaultTransport.IdleConnTimeout, 54 | TLSHandshakeTimeout: defaultTransport.TLSHandshakeTimeout, 55 | ExpectContinueTimeout: defaultTransport.ExpectContinueTimeout, 56 | } 57 | transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} 58 | ret.client = &http.Client{Transport: transport} 59 | } 60 | return ret 61 | } 62 | 63 | func (c *NetHostClient) Get(path, query string, out *HostResponse) (int, error) { 64 | url := *c.url // Shallow copy 65 | url.Path = path 66 | url.RawQuery = query 67 | res, err := c.client.Get(url.String()) 68 | if err != nil { 69 | return -1, fmt.Errorf("failed to connect to device host: %w", err) 70 | } 71 | defer res.Body.Close() 72 | if out != nil { 73 | err = parseReply(res, out.Result, out.Error) 74 | } 75 | return res.StatusCode, err 76 | } 77 | 78 | func (c *NetHostClient) Post(path, query string, bodyJSON any, out *HostResponse) (int, error) { 79 | bodyStr, err := json.Marshal(bodyJSON) 80 | if err != nil { 81 | return -1, fmt.Errorf("failed to parse JSON request: %w", err) 82 | } 83 | url := *c.url // Shallow copy 84 | url.Path = path 85 | url.RawQuery = query 86 | res, err := c.client.Post(url.String(), "application/json", bytes.NewBuffer(bodyStr)) 87 | if err != nil { 88 | return -1, fmt.Errorf("failed to connecto to device host: %w", err) 89 | } 90 | defer res.Body.Close() 91 | if out != nil { 92 | err = parseReply(res, out.Result, out.Error) 93 | } 94 | return res.StatusCode, err 95 | } 96 | 97 | func (c *NetHostClient) GetReverseProxy() *httputil.ReverseProxy { 98 | devProxy := httputil.NewSingleHostReverseProxy(c.url) 99 | if c.client != http.DefaultClient { 100 | // Make sure the reverse proxy has the same customizations as the http client. 101 | devProxy.Transport = c.client.Transport 102 | } 103 | devProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { 104 | log.Printf("request %q failed: proxy error: %v", r.Method+" "+r.URL.Path, err) 105 | w.Header().Add("x-cutf-proxy", "co-host") 106 | w.WriteHeader(http.StatusBadGateway) 107 | } 108 | return devProxy 109 | } 110 | 111 | func parseReply(res *http.Response, resObj any, resErr *apiv1.Error) error { 112 | var err error 113 | dec := json.NewDecoder(res.Body) 114 | if res.StatusCode < 200 || res.StatusCode > 299 { 115 | err = dec.Decode(resErr) 116 | } else { 117 | err = dec.Decode(resObj) 118 | } 119 | if err != nil { 120 | return fmt.Errorf("failed to parse device response: %w", err) 121 | } 122 | return nil 123 | } 124 | -------------------------------------------------------------------------------- /pkg/app/instances/instances.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package instances 16 | 17 | import ( 18 | "net/http/httputil" 19 | 20 | apiv1 "github.com/google/cloud-android-orchestration/api/v1" 21 | "github.com/google/cloud-android-orchestration/pkg/app/accounts" 22 | ) 23 | 24 | type Manager interface { 25 | // List zones 26 | ListZones() (*apiv1.ListZonesResponse, error) 27 | // Creates a host instance. 28 | CreateHost(zone string, req *apiv1.CreateHostRequest, user accounts.User) (*apiv1.Operation, error) 29 | // List hosts 30 | ListHosts(zone string, user accounts.User, req *ListHostsRequest) (*apiv1.ListHostsResponse, error) 31 | // Deletes the given host instance. 32 | DeleteHost(zone string, user accounts.User, name string) (*apiv1.Operation, error) 33 | // Waits until operation is DONE or earlier. If DONE return the expected response of the operation. If the 34 | // original method returns no data on success, such as `Delete`, response will be empty. If the original method 35 | // is standard `Get`/`Create`/`Update`, the response should be the relevant resource. 36 | WaitOperation(zone string, user accounts.User, name string) (any, error) 37 | // Creates a connector to the given host. 38 | GetHostClient(zone string, host string) (HostClient, error) 39 | } 40 | 41 | type HostClient interface { 42 | // Get and Post requests return the HTTP status code or an error. 43 | // The response body is parsed into the res output parameter if provided. 44 | Get(URLPath, URLQuery string, res *HostResponse) (int, error) 45 | Post(URLPath, URLQuery string, bodyJSON any, res *HostResponse) (int, error) 46 | GetReverseProxy() *httputil.ReverseProxy 47 | } 48 | 49 | type HostResponse struct { 50 | Result any 51 | Error *apiv1.Error 52 | } 53 | 54 | type ListHostsRequest struct { 55 | // The maximum number of results per page that should be returned. If the number of available results is larger 56 | // than MaxResults, a `NextPageToken` will be returned which can be used to get the next page of results 57 | // in subsequent List requests. 58 | MaxResults uint32 59 | // Specifies a page token to use. 60 | // Use the `NextPageToken` value returned by a previous List request. 61 | PageToken string 62 | } 63 | 64 | type IMType string 65 | 66 | type Config struct { 67 | Type IMType 68 | // The protocol the host orchestrator expects, either http or https 69 | HostOrchestratorProtocol string 70 | AllowSelfSignedHostSSLCertificate bool 71 | GCP *GCPIMConfig 72 | UNIX *UNIXIMConfig 73 | Docker *DockerIMConfig 74 | } 75 | -------------------------------------------------------------------------------- /pkg/app/instances/local.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package instances 16 | 17 | import ( 18 | "fmt" 19 | "net/url" 20 | 21 | apiv1 "github.com/google/cloud-android-orchestration/api/v1" 22 | "github.com/google/cloud-android-orchestration/pkg/app/accounts" 23 | ) 24 | 25 | const UnixIMType IMType = "unix" 26 | 27 | type UNIXIMConfig struct { 28 | HostOrchestratorPort int 29 | } 30 | 31 | // Implements the Manager interface providing access to the first 32 | // device in the local host orchestrator. 33 | // This implementation is useful for both development and testing 34 | type LocalInstanceManager struct { 35 | config Config 36 | } 37 | 38 | func NewLocalInstanceManager(cfg Config) *LocalInstanceManager { 39 | return &LocalInstanceManager{ 40 | config: cfg, 41 | } 42 | } 43 | 44 | func (m *LocalInstanceManager) GetHostAddr(_ string, _ string) (string, error) { 45 | return "127.0.0.1", nil 46 | } 47 | 48 | func (m *LocalInstanceManager) GetHostURL(zone string, host string) (*url.URL, error) { 49 | addr, err := m.GetHostAddr(zone, host) 50 | if err != nil { 51 | return nil, err 52 | } 53 | return url.Parse(fmt.Sprintf("%s://%s:%d", m.config.HostOrchestratorProtocol, addr, m.config.UNIX.HostOrchestratorPort)) 54 | } 55 | 56 | func (m *LocalInstanceManager) ListZones() (*apiv1.ListZonesResponse, error) { 57 | return &apiv1.ListZonesResponse{ 58 | Items: []*apiv1.Zone{{ 59 | Name: "local", 60 | }}, 61 | }, nil 62 | } 63 | 64 | func (m *LocalInstanceManager) CreateHost(_ string, _ *apiv1.CreateHostRequest, _ accounts.User) (*apiv1.Operation, error) { 65 | return &apiv1.Operation{ 66 | Name: "Create Host", 67 | Done: true, 68 | }, nil 69 | } 70 | 71 | func (m *LocalInstanceManager) ListHosts(zone string, user accounts.User, req *ListHostsRequest) (*apiv1.ListHostsResponse, error) { 72 | return &apiv1.ListHostsResponse{ 73 | Items: []*apiv1.HostInstance{{ 74 | Name: "local", 75 | }}, 76 | }, nil 77 | } 78 | 79 | func (m *LocalInstanceManager) DeleteHost(zone string, user accounts.User, name string) (*apiv1.Operation, error) { 80 | return nil, fmt.Errorf("%T#DeleteHost is not implemented", *m) 81 | } 82 | 83 | func (m *LocalInstanceManager) WaitOperation(zone string, user accounts.User, name string) (any, error) { 84 | return nil, fmt.Errorf("%T#WaitOperation is not implemented", *m) 85 | } 86 | 87 | func (m *LocalInstanceManager) GetHostClient(zone string, host string) (HostClient, error) { 88 | url, err := m.GetHostURL(zone, host) 89 | if err != nil { 90 | return nil, err 91 | } 92 | return NewNetHostClient(url, m.config.AllowSelfSignedHostSSLCertificate), nil 93 | } 94 | -------------------------------------------------------------------------------- /pkg/app/oauth2/oauth2.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package oauth2 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "net/http" 21 | "net/url" 22 | 23 | "github.com/google/cloud-android-orchestration/pkg/app/secrets" 24 | 25 | "golang.org/x/oauth2" 26 | "golang.org/x/oauth2/google" 27 | ) 28 | 29 | type OAuth2Config struct { 30 | Provider string 31 | RedirectURL string 32 | } 33 | 34 | const ( 35 | GoogleOAuth2Provider = "Google" 36 | ) 37 | 38 | type Helper struct { 39 | oauth2.Config 40 | Revoke func(*oauth2.Token) error 41 | } 42 | 43 | // Build a oauth2.Config object with Google as the provider. 44 | func NewGoogleOAuth2Helper(redirectURL string, sm secrets.SecretManager) *Helper { 45 | return &Helper{ 46 | Config: oauth2.Config{ 47 | ClientID: sm.OAuth2ClientID(), 48 | ClientSecret: sm.OAuth2ClientSecret(), 49 | Scopes: []string{ 50 | "https://www.googleapis.com/auth/androidbuild.internal", 51 | "openid", 52 | "email", 53 | }, 54 | RedirectURL: redirectURL, 55 | Endpoint: google.Endpoint, 56 | }, 57 | Revoke: RevokeGoogleOAuth2Token, 58 | } 59 | } 60 | 61 | func RevokeGoogleOAuth2Token(tk *oauth2.Token) error { 62 | if tk == nil { 63 | return fmt.Errorf("nil Token") 64 | } 65 | _, err := http.DefaultClient.PostForm( 66 | "https://oauth2.googleapis.com/revoke", 67 | url.Values{"token": []string{tk.AccessToken}}) 68 | return err 69 | } 70 | 71 | // ID tokens (from OpenID connect) are presented in JWT format, with the relevant fields in the Claims section. 72 | type IDTokenClaims map[string]interface{} 73 | 74 | func (c IDTokenClaims) Email() (string, error) { 75 | v, ok := c["email"] 76 | if !ok { 77 | return "", errors.New("no email in id token") 78 | } 79 | vstr, ok := v.(string) 80 | if !ok { 81 | return "", errors.New("malformed email in id token") 82 | } 83 | return vstr, nil 84 | } 85 | -------------------------------------------------------------------------------- /pkg/app/secrets/empty.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package secrets 16 | 17 | const EmptySMType = "" 18 | 19 | // A secret manager that always returns empty string. 20 | type EmptySecretManager struct{} 21 | 22 | func NewEmptySecretManager() *EmptySecretManager { 23 | return &EmptySecretManager{} 24 | } 25 | 26 | func (sm *EmptySecretManager) OAuth2ClientID() string { 27 | return "" 28 | } 29 | 30 | func (sm *EmptySecretManager) OAuth2ClientSecret() string { 31 | return "" 32 | } 33 | -------------------------------------------------------------------------------- /pkg/app/secrets/gcp.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package secrets 16 | 17 | import ( 18 | "context" 19 | "encoding/json" 20 | "fmt" 21 | 22 | secretmanager "cloud.google.com/go/secretmanager/apiv1" 23 | "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" 24 | ) 25 | 26 | const GCPSMType = "GCP" 27 | 28 | type GCPSMConfig struct { 29 | OAuth2ClientResourceID string 30 | } 31 | 32 | type ClientSecrets struct { 33 | ClientID string `json:"client_id"` 34 | ClientSecret string `json:"client_secret"` 35 | } 36 | 37 | type GCPSecretManager struct { 38 | secrets ClientSecrets 39 | } 40 | 41 | func NewGCPSecretManager(config *GCPSMConfig) (*GCPSecretManager, error) { 42 | ctx := context.TODO() 43 | client, err := secretmanager.NewClient(ctx) 44 | if err != nil { 45 | return nil, fmt.Errorf("failed to create secret manager client: %w", err) 46 | } 47 | defer client.Close() 48 | 49 | accessRequest := &secretmanagerpb.AccessSecretVersionRequest{Name: config.OAuth2ClientResourceID} 50 | result, err := client.AccessSecretVersion(ctx, accessRequest) 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to access secret: %w", err) 53 | } 54 | sm := &GCPSecretManager{} 55 | err = json.Unmarshal(result.Payload.Data, &sm.secrets) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to decode secrets: %w", err) 58 | } 59 | return sm, nil 60 | } 61 | 62 | func (s *GCPSecretManager) OAuth2ClientID() string { 63 | return s.secrets.ClientID 64 | } 65 | 66 | func (s *GCPSecretManager) OAuth2ClientSecret() string { 67 | return s.secrets.ClientSecret 68 | } 69 | -------------------------------------------------------------------------------- /pkg/app/secrets/local.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package secrets 16 | 17 | import ( 18 | "encoding/json" 19 | "fmt" 20 | "os" 21 | ) 22 | 23 | const UnixSMType = "unix" 24 | 25 | type UnixSMConfig struct { 26 | SecretFilePath string 27 | } 28 | 29 | // A secret manager that reads secrets from a file in JSON format. 30 | type FromFileSecretManager struct { 31 | ClientID string `json:"client_id"` 32 | ClientSecret string `json:"client_secret"` 33 | } 34 | 35 | func NewFromFileSecretManager(path string) (*FromFileSecretManager, error) { 36 | r, err := os.Open(path) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to open secrets file: %w", err) 39 | } 40 | dec := json.NewDecoder(r) 41 | var sm FromFileSecretManager 42 | if err := dec.Decode(&sm); err != nil { 43 | return nil, err 44 | } 45 | return &sm, nil 46 | } 47 | 48 | func (sm *FromFileSecretManager) OAuth2ClientID() string { 49 | return sm.ClientID 50 | } 51 | 52 | func (sm *FromFileSecretManager) OAuth2ClientSecret() string { 53 | return sm.ClientSecret 54 | } 55 | -------------------------------------------------------------------------------- /pkg/app/secrets/secrets.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package secrets 16 | 17 | type SecretManager interface { 18 | OAuth2ClientID() string 19 | OAuth2ClientSecret() string 20 | } 21 | 22 | type SMType string 23 | 24 | type Config struct { 25 | Type SMType 26 | GCP *GCPSMConfig 27 | UNIX *UnixSMConfig 28 | } 29 | -------------------------------------------------------------------------------- /pkg/app/session/session.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package session 16 | 17 | type Session struct { 18 | Key string 19 | OAuth2State string 20 | } 21 | -------------------------------------------------------------------------------- /pkg/cli/adbserver.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cli 16 | 17 | import ( 18 | "fmt" 19 | "net" 20 | ) 21 | 22 | type ADBServerProxy interface { 23 | Connect(port int) error 24 | Disconnect(port int) error 25 | } 26 | 27 | const ADBServerPort = 5037 28 | 29 | type ADBServerProxyImpl struct{} 30 | 31 | func (p *ADBServerProxyImpl) connect(adbSerial string) error { 32 | msg := fmt.Sprintf("host:connect:%s", adbSerial) 33 | return p.sendMsg(msg) 34 | } 35 | 36 | func (p *ADBServerProxyImpl) Connect(port int) error { 37 | return p.connect(fmt.Sprintf("127.0.0.1:%d", port)) 38 | } 39 | 40 | func (p *ADBServerProxyImpl) disconnect(adbSerial string) error { 41 | msg := fmt.Sprintf("host:disconnect:%s", adbSerial) 42 | return p.sendMsg(msg) 43 | } 44 | 45 | func (p *ADBServerProxyImpl) Disconnect(port int) error { 46 | return p.disconnect(fmt.Sprintf("127.0.0.1:%d", port)) 47 | } 48 | 49 | func (*ADBServerProxyImpl) sendMsg(msg string) error { 50 | msg = fmt.Sprintf("%.4x%s", len(msg), msg) 51 | conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", ADBServerPort)) 52 | if err != nil { 53 | return fmt.Errorf("unable to contact ADB server: %w", err) 54 | } 55 | defer conn.Close() 56 | written := 0 57 | for written < len(msg) { 58 | n, err := conn.Write([]byte(msg[written:])) 59 | if err != nil { 60 | return fmt.Errorf("error sending message to ADB server: %w", err) 61 | } 62 | written += n 63 | } 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /pkg/cli/authz/oauth.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package authz 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "fmt" 21 | "log" 22 | "math/rand" 23 | "net/http" 24 | "net/url" 25 | 26 | "golang.org/x/oauth2" 27 | "golang.org/x/oauth2/google" 28 | ) 29 | 30 | func createServer(ch chan string, state string, port string) *http.Server { 31 | return &http.Server{ 32 | Addr: ":" + port, 33 | Handler: http.HandlerFunc(handler(ch, state)), 34 | } 35 | } 36 | 37 | func handler(ch chan string, randState string) func(http.ResponseWriter, *http.Request) { 38 | return func(rw http.ResponseWriter, req *http.Request) { 39 | if req.URL.Path == "/favicon.ico" { 40 | http.Error(rw, "error: visiting /favicon.ico", 404) 41 | return 42 | } 43 | if req.FormValue("state") != randState { 44 | log.Printf("state: %s doesn't match. (expected: %s)", req.FormValue("state"), randState) 45 | http.Error(rw, "invalid state", 500) 46 | return 47 | } 48 | if code := req.FormValue("code"); code != "" { 49 | fmt.Fprintf(rw, "

Success

Authorized.") 50 | rw.(http.Flusher).Flush() 51 | ch <- code 52 | return 53 | } 54 | ch <- "" 55 | http.Error(rw, "invalid code", 500) 56 | } 57 | } 58 | 59 | func OAuthAccessToken(credential []byte) (*oauth2.Token, error) { 60 | oauthConfig, err := google.ConfigFromJSON(credential, "https://www.googleapis.com/auth/androidbuild.internal") 61 | if err != nil { 62 | return nil, err 63 | } 64 | redirectURL, err := url.Parse(oauthConfig.RedirectURL) 65 | if err != nil { 66 | return nil, fmt.Errorf("parse redirect url error: %w", err) 67 | } 68 | if redirectURL.Hostname() != "localhost" { 69 | return nil, errors.New("the redirect url should be `http://localhost:`") 70 | } 71 | port := redirectURL.Port() 72 | if port == "" { 73 | return nil, errors.New("empty port, the redirect url should be `http://localhost:`") 74 | } 75 | 76 | ch := make(chan string) 77 | ctx := context.Background() 78 | 79 | // generate a random hex string 80 | state := fmt.Sprintf("%.16x%.16x%.16x%.16x", rand.Uint64(), rand.Uint64(), rand.Uint64(), rand.Uint64()) 81 | ts := createServer(ch, state, port) 82 | go func() { 83 | err := ts.ListenAndServe() 84 | if err != nil { 85 | log.Println(err) 86 | } 87 | }() 88 | 89 | defer ts.Close() 90 | authURL := oauthConfig.AuthCodeURL(state) 91 | log.Printf("Authorize this app at: %s", authURL) 92 | code := <-ch 93 | 94 | tk, err := oauthConfig.Exchange(ctx, code) 95 | if err != nil { 96 | return nil, err 97 | } 98 | return tk, nil 99 | } 100 | 101 | func JWTAccessToken(credential []byte) (*oauth2.Token, error) { 102 | jwtConfig, err := google.JWTConfigFromJSON(credential, "https://www.googleapis.com/auth/androidbuild.internal") 103 | if err != nil { 104 | return nil, err 105 | } 106 | tk, err := jwtConfig.TokenSource(context.Background()).Token() 107 | if err != nil { 108 | return nil, err 109 | } 110 | return tk, nil 111 | } 112 | -------------------------------------------------------------------------------- /pkg/cli/cvd_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cli 16 | 17 | import ( 18 | "os" 19 | "path" 20 | "path/filepath" 21 | "testing" 22 | 23 | "github.com/google/go-cmp/cmp" 24 | ) 25 | 26 | func TestListLocalImageRequiredFiles(t *testing.T) { 27 | tmpDir := t.TempDir() 28 | reqImgsFileDir := filepath.Join(tmpDir, path.Dir(RequiredImagesFilename)) 29 | imagesFile := filepath.Join(tmpDir, RequiredImagesFilename) 30 | err := os.MkdirAll(reqImgsFileDir, 0750) 31 | if err != nil && !os.IsExist(err) { 32 | t.Fatal(err) 33 | } 34 | err = os.WriteFile(imagesFile, []byte("foo\nbar\nbaz\n"), 0660) 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | 39 | got, err := ListLocalImageRequiredFiles(tmpDir, "/out/host/linux-x86") 40 | 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | expected := []string{ 45 | "/out/host/linux-x86/foo", 46 | "/out/host/linux-x86/bar", 47 | "/out/host/linux-x86/baz", 48 | } 49 | if diff := cmp.Diff(expected, got); diff != "" { 50 | t.Errorf("mismatch (-want +got):\n%s", diff) 51 | } 52 | } 53 | 54 | func TestAdditionalInstancesNum(t *testing.T) { 55 | tests := []struct { 56 | in int 57 | ex uint32 58 | }{ 59 | {in: -1, ex: 0}, 60 | {in: 0, ex: 0}, 61 | {in: 1, ex: 0}, 62 | {in: 100, ex: 99}, 63 | } 64 | for _, tc := range tests { 65 | opts := &CreateCVDOpts{NumInstances: tc.in} 66 | 67 | got := opts.AdditionalInstancesNum() 68 | 69 | if diff := cmp.Diff(tc.ex, got); diff != "" { 70 | t.Errorf("mismatch (-want +got):\n%s", diff) 71 | } 72 | } 73 | } 74 | 75 | func TestGetHostOutRelativePathSucceeds(t *testing.T) { 76 | targetArch := "x86_64" 77 | 78 | got, err := GetHostOutRelativePath(targetArch) 79 | 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | expected := "out/host/linux-x86" 84 | if diff := cmp.Diff(expected, got); diff != "" { 85 | t.Errorf("mismatch (-want +got):\n%s", diff) 86 | } 87 | } 88 | 89 | func TestGetHostOutRelativePathFailsUnknownTargetArch(t *testing.T) { 90 | targetArch := "unknown" 91 | 92 | _, err := GetHostOutRelativePath(targetArch) 93 | 94 | if err == nil { 95 | t.Errorf("expected error") 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pkg/cli/host.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cli 16 | 17 | import ( 18 | apiv1 "github.com/google/cloud-android-orchestration/api/v1" 19 | "github.com/google/cloud-android-orchestration/pkg/client" 20 | ) 21 | 22 | type CreateHostOpts struct { 23 | GCP CreateGCPHostOpts 24 | } 25 | 26 | func (f *CreateHostOpts) Update(s *Service) { 27 | f.GCP.MachineType = s.Host.GCP.MachineType 28 | f.GCP.MinCPUPlatform = s.Host.GCP.MinCPUPlatform 29 | f.GCP.BootDiskSizeGB = s.Host.GCP.BootDiskSizeGB 30 | } 31 | 32 | type CreateGCPHostOpts struct { 33 | MachineType string 34 | MinCPUPlatform string 35 | BootDiskSizeGB int64 36 | AcceleratorConfigs []acceleratorConfig 37 | } 38 | 39 | func createHost(srvClient client.Client, opts CreateHostOpts) (*apiv1.HostInstance, error) { 40 | req := apiv1.CreateHostRequest{ 41 | HostInstance: &apiv1.HostInstance{ 42 | GCP: &apiv1.GCPInstance{ 43 | MachineType: opts.GCP.MachineType, 44 | MinCPUPlatform: opts.GCP.MinCPUPlatform, 45 | BootDiskSizeGB: opts.GCP.BootDiskSizeGB, 46 | }, 47 | }, 48 | } 49 | if len(opts.GCP.AcceleratorConfigs) != 0 { 50 | s := []*apiv1.AcceleratorConfig{} 51 | for _, c := range opts.GCP.AcceleratorConfigs { 52 | c := &apiv1.AcceleratorConfig{AcceleratorCount: int64(c.Count), AcceleratorType: c.Type} 53 | s = append(s, c) 54 | } 55 | req.HostInstance.GCP.AcceleratorConfigs = s 56 | } 57 | return srvClient.CreateHost(&req) 58 | } 59 | 60 | func hostnames(srvClient client.Client) ([]string, error) { 61 | hosts, err := srvClient.ListHosts() 62 | if err != nil { 63 | return nil, err 64 | } 65 | result := []string{} 66 | for _, h := range hosts.Items { 67 | result = append(result, h.Name) 68 | } 69 | return result, nil 70 | } 71 | -------------------------------------------------------------------------------- /pkg/cli/testing.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "io" 5 | "net/url" 6 | 7 | apiv1 "github.com/google/cloud-android-orchestration/api/v1" 8 | 9 | hoapi "github.com/google/android-cuttlefish/frontend/src/host_orchestrator/api/v1" 10 | hoclient "github.com/google/android-cuttlefish/frontend/src/libhoclient" 11 | wclient "github.com/google/android-cuttlefish/frontend/src/libhoclient/webrtcclient" 12 | "github.com/gorilla/websocket" 13 | ) 14 | 15 | const unitTestServiceURL = "test://unit" 16 | 17 | type fakeClient struct{} 18 | 19 | func (fakeClient) CreateHost(req *apiv1.CreateHostRequest) (*apiv1.HostInstance, error) { 20 | return &apiv1.HostInstance{Name: "foo"}, nil 21 | } 22 | 23 | func (fakeClient) ListHosts() (*apiv1.ListHostsResponse, error) { 24 | return &apiv1.ListHostsResponse{ 25 | Items: []*apiv1.HostInstance{{Name: "foo"}, {Name: "bar"}}, 26 | }, nil 27 | } 28 | 29 | func (fakeClient) DeleteHosts(name []string) error { 30 | return nil 31 | } 32 | 33 | func (fakeClient) RootURI() string { 34 | return unitTestServiceURL + "/v1" 35 | } 36 | 37 | func (fakeClient) HostService(host string) hoclient.HostOrchestratorService { 38 | if host == "" { 39 | panic("empty host") 40 | } 41 | return &fakeHostService{} 42 | } 43 | 44 | func (fakeClient) HostServiceURL(host string) (*url.URL, error) { 45 | return nil, nil 46 | } 47 | 48 | type fakeHostService struct{} 49 | 50 | func (fakeHostService) GetInfraConfig() (*apiv1.InfraConfig, error) { 51 | return nil, nil 52 | } 53 | 54 | func (fakeHostService) ConnectWebRTC(device string, observer wclient.Observer, logger io.Writer, opts hoclient.ConnectWebRTCOpts) (*wclient.Connection, error) { 55 | return nil, nil 56 | } 57 | 58 | func (fakeHostService) ConnectADBWebSocket(device string) (*websocket.Conn, error) { 59 | return nil, nil 60 | } 61 | 62 | func (fakeHostService) FetchArtifacts(req *hoapi.FetchArtifactsRequest, creds hoclient.BuildAPICreds) (*hoapi.FetchArtifactsResponse, error) { 63 | return &hoapi.FetchArtifactsResponse{AndroidCIBundle: &hoapi.AndroidCIBundle{}}, nil 64 | } 65 | 66 | func (fakeHostService) CreateCVD(req *hoapi.CreateCVDRequest, creds hoclient.BuildAPICreds) (*hoapi.CreateCVDResponse, error) { 67 | return &hoapi.CreateCVDResponse{CVDs: []*hoapi.CVD{{Name: "cvd-1"}}}, nil 68 | } 69 | 70 | func (fakeHostService) CreateCVDOp(req *hoapi.CreateCVDRequest, creds hoclient.BuildAPICreds) (*hoapi.Operation, error) { 71 | return nil, nil 72 | } 73 | 74 | func (fakeHostService) DeleteCVD(id string) error { 75 | return nil 76 | } 77 | 78 | func (fakeHostService) ListCVDs() ([]*hoapi.CVD, error) { 79 | return []*hoapi.CVD{{Name: "cvd-1"}}, nil 80 | } 81 | 82 | func (fakeHostService) CreateUploadDir() (string, error) { 83 | return "", nil 84 | } 85 | 86 | func (fakeHostService) UploadFile(uploadDir string, name string) error { 87 | return nil 88 | } 89 | 90 | func (fakeHostService) UploadFileWithOptions(uploadDir string, name string, options hoclient.UploadOptions) error { 91 | return nil 92 | } 93 | 94 | func (fakeHostService) ExtractFile(string, string) (*hoapi.Operation, error) { return nil, nil } 95 | 96 | func (fakeHostService) DownloadRuntimeArtifacts(dst io.Writer) error { 97 | return nil 98 | } 99 | 100 | func (fakeHostService) WaitForOperation(string, any) error { return nil } 101 | 102 | func (fakeHostService) CreateBugReport(string, hoclient.CreateBugReportOpts, io.Writer) error { 103 | return nil 104 | } 105 | 106 | func (fakeHostService) Powerwash(groupName, instanceName string) error { return nil } 107 | 108 | func (fakeHostService) Stop(groupName, instanceName string) error { return nil } 109 | 110 | func (fakeHostService) Start(groupName, instanceName string, req *hoapi.StartCVDRequest) error { 111 | return nil 112 | } 113 | 114 | func (fakeHostService) CreateSnapshot(groupName, instanceName string, req *hoapi.CreateSnapshotRequest) (*hoapi.CreateSnapshotResponse, error) { 115 | return nil, nil 116 | } 117 | 118 | func (fakeHostService) Powerbtn(groupName, instanceName string) error { 119 | return nil 120 | } 121 | -------------------------------------------------------------------------------- /pkg/client/client_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package client 16 | 17 | import ( 18 | "encoding/json" 19 | "io" 20 | "net/http" 21 | "net/http/httptest" 22 | "regexp" 23 | "testing" 24 | 25 | apiv1 "github.com/google/cloud-android-orchestration/api/v1" 26 | 27 | "github.com/hashicorp/go-multierror" 28 | ) 29 | 30 | func TestDeleteHosts(t *testing.T) { 31 | existingNames := map[string]struct{}{"bar": {}, "baz": {}} 32 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 | if r.Method != "DELETE" { 34 | panic("unexpected method: " + r.Method) 35 | } 36 | re := regexp.MustCompile(`^/hosts/(.*)$`) 37 | matches := re.FindStringSubmatch(r.URL.Path) 38 | if len(matches) != 2 { 39 | panic("unexpected path: " + r.URL.Path) 40 | } 41 | if _, ok := existingNames[matches[1]]; ok { 42 | writeOK(w, "") 43 | } else { 44 | writeErr(w, 404) 45 | } 46 | })) 47 | defer ts.Close() 48 | opts := &ClientOptions{ 49 | RootEndpoint: ts.URL, 50 | DumpOut: io.Discard, 51 | } 52 | c, _ := NewClient(opts) 53 | 54 | err := c.DeleteHosts([]string{"foo", "bar", "baz", "quz"}) 55 | 56 | merr, _ := err.(*multierror.Error) 57 | errs := merr.WrappedErrors() 58 | if len(errs) != 2 { 59 | t.Errorf("expected 2, got: %d", len(errs)) 60 | } 61 | } 62 | 63 | func writeErr(w http.ResponseWriter, statusCode int) { 64 | write(w, &apiv1.Error{Code: statusCode}, statusCode) 65 | } 66 | 67 | func writeOK(w http.ResponseWriter, data any) { 68 | write(w, data, http.StatusOK) 69 | } 70 | 71 | func write(w http.ResponseWriter, data any, statusCode int) { 72 | w.WriteHeader(statusCode) 73 | w.Header().Set("Content-Type", "application/json") 74 | encoder := json.NewEncoder(w) 75 | encoder.Encode(data) 76 | } 77 | -------------------------------------------------------------------------------- /scripts/gcp/app/app.yaml.tmpl: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google Inc. All rights reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Template to generate App Engine `app.yaml` files. 16 | # 17 | # export PROJECT_ID="foo" 18 | # export SERVERLESS_VPC_REGION="us-central1" 19 | # export SERVICE="default" 20 | # envsubst < app.yaml.tmpl > app.yaml 21 | 22 | service: ${SERVICE} 23 | runtime: go122 24 | main: ./cmd/cloud_orchestrator 25 | vpc_access_connector: 26 | name: "projects/${PROJECT_ID}/locations/${SERVERLESS_VPC_REGION}/connectors/co-vpc-connector" 27 | handlers: 28 | - url: /.* 29 | script: _go_app 30 | login: required 31 | -------------------------------------------------------------------------------- /scripts/gcp/app/conf.toml.tmpl: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google Inc. All rights reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Template to generate the service `conf.toml` file 16 | # 17 | # export PROJECT_ID="foo" 18 | # envsubst < conf.toml.tmpl > conf.toml 19 | 20 | WebStaticFilesPath = "web" 21 | 22 | [AccountManager] 23 | Type = "GCP" 24 | 25 | # TODO: This could be emtpy for gae vanilla aosp. 26 | [AccountManager.OAuth2] 27 | Provider = "Google" 28 | RedirectURL = "http://foo.bar/oauth2callback" 29 | 30 | [SecretManager] 31 | Type = "" 32 | 33 | [EncryptionService] 34 | Type = "Fake" 35 | 36 | [DatabaseService] 37 | Type = "InMemory" 38 | 39 | [InstanceManager] 40 | Type = "GCP" 41 | HostOrchestratorProtocol = "https" 42 | AllowSelfSignedHostSSLCertificate = true 43 | 44 | [InstanceManager.GCP] 45 | ProjectID = "${PROJECT_ID}" 46 | HostImageFamily = "projects/${PROJECT_ID}/global/images/family/cf-debian11-amd64" 47 | HostOrchestratorPort = 2443 48 | Network = "projects/${PROJECT_ID}/global/networks/default" 49 | Subnetwork = "" 50 | UseExternalIP = true 51 | 52 | [[WebRTC.IceServers]] 53 | URLs = ["stun:stun.l.google.com:19302"] 54 | -------------------------------------------------------------------------------- /scripts/gcp/app/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2024 Google Inc. All rights reserved. 4 | 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # Deploy the Cloud Orchestration app. 18 | 19 | set -e 20 | 21 | usage() { 22 | echo "usage: $0 -p project-name [-s service-name]" 23 | } 24 | 25 | project= 26 | service="default" 27 | 28 | while getopts ":hp:s:" opt; do 29 | case "${opt}" in 30 | h) 31 | usage 32 | exit 0 33 | ;; 34 | p) 35 | project="${OPTARG}" 36 | ;; 37 | s) 38 | service="${OPTARG}" 39 | ;; 40 | \?) 41 | echo "Invalid option: ${OPTARG}" >&2 42 | usage 43 | exit 1 44 | ;; 45 | :) 46 | echo "Invalid option: ${OPTARG} requires an argument" >&2 47 | usage 48 | exit 1 49 | ;; 50 | esac 51 | done 52 | 53 | gcloud config set project $project 54 | 55 | app_region=$(gcloud app describe --format="value(locationId)") 56 | # Note: Two locations, which are called europe-west and us-central in App Engine commands and in the Google Cloud 57 | # console, are called europe-west1 and us-central1, respectively, elsewhere in Google documentation. 58 | # https://cloud.google.com/appengine/docs/standard/locations 59 | if [ "${app_region}" = "europe-west" ] || [ "${app_region}" = "us-central" ]; then 60 | app_region="${app_region}1" 61 | fi 62 | serverless_vpc_region="${app_region}" 63 | 64 | script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) 65 | 66 | # Generates app.yaml 67 | export PROJECT_ID="${project}" 68 | export SERVERLESS_VPC_REGION="${serverless_vpc_region}" 69 | export SERVICE="${service}" 70 | 71 | readonly VARS='\ 72 | $PROJECT_ID\ 73 | $SERVERLESS_VPC_REGION\ 74 | $SERVICE' 75 | 76 | envsubst "$VARS" < ${script_dir}/app.yaml.tmpl > app.yaml 77 | 78 | # Generates conf.toml 79 | export PROJECT_ID="${project}" 80 | envsubst '$PROJECT_ID' < ${script_dir}/conf.toml.tmpl > conf.toml 81 | 82 | sudo apt-get install google-cloud-sdk-app-engine-go 83 | 84 | # Deploy 85 | gcloud app deploy 86 | 87 | -------------------------------------------------------------------------------- /scripts/gcp/app/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2024 Google Inc. All rights reserved. 4 | 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # One time project setup. 18 | 19 | usage() { 20 | echo "usage: $0 -p project-name" 21 | } 22 | 23 | project= 24 | 25 | while getopts ":hp:" opt; do 26 | case "${opt}" in 27 | h) 28 | usage 29 | exit 0 30 | ;; 31 | p) 32 | project="${OPTARG}" 33 | ;; 34 | \?) 35 | echo "Invalid option: ${OPTARG}" >&2 36 | usage 37 | exit 1 38 | ;; 39 | :) 40 | echo "Invalid option: ${OPTARG} requires an argument" >&2 41 | usage 42 | exit 1 43 | ;; 44 | esac 45 | done 46 | 47 | network="default" 48 | 49 | gcloud config set project $project 50 | 51 | # Creates the App Engine if does not exist yet. 52 | gcloud app describe 1> /dev/null 2> /dev/null 53 | if [ $? -ne 0 ]; then 54 | gcloud app create 55 | fi 56 | 57 | app_region=$(gcloud app describe --format="value(locationId)") 58 | # Note: Two locations, which are called europe-west and us-central in App Engine commands and in the Google Cloud 59 | # console, are called europe-west1 and us-central1, respectively, elsewhere in Google documentation. 60 | # https://cloud.google.com/appengine/docs/standard/locations 61 | if [ "${app_region}" = "europe-west" ] || [ "${app_region}" = "us-central" ]; then 62 | app_region="${app_region}1" 63 | fi 64 | echo "App Region: ${app_region}" 65 | 66 | # Grant GAE service account admin role. 67 | gae_service_account_name="$project@appspot.gserviceaccount.com" 68 | gcloud projects add-iam-policy-binding $project \ 69 | --member="serviceAccount:$gae_service_account_name" \ 70 | --role="roles/compute.admin" 71 | 72 | gcloud services enable vpcaccess.googleapis.com 73 | 74 | serverless_vpc_region="${app_region}" 75 | connector=$(gcloud compute networks vpc-access connectors list --filter="name:co-vpc-connector" \ 76 | --region=${serverless_vpc_region}) 77 | if [ -z "${connector}" ]; then 78 | gcloud compute networks vpc-access connectors create co-vpc-connector \ 79 | --region=${serverless_vpc_region} \ 80 | --network=${network} \ 81 | --range=10.8.0.0/28 82 | fi 83 | -------------------------------------------------------------------------------- /scripts/gcp/cloudrun/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/google" { 5 | version = "5.34.0" 6 | constraints = ">= 4.50.0, < 6.0.0" 7 | hashes = [ 8 | "h1:fi9xftzKft61lt4vI0oXgzEwpLtMW4D3S3/T63SsO7w=", 9 | "zh:143c88bb74631041c291ebf7b6213467bf376d3775a33815785939dc102fac09", 10 | "zh:1616ac79345f472b33fcc388eaf4a8a3002e4cc3a5e8748d60d6f4786d0d16dc", 11 | "zh:554ce78e73349ac2c893a74b6981f5e55169ca16f4d0f239e6ccdecadbe1c9e1", 12 | "zh:8022f97aa907685b2eb6c39d5411cf2be2448c6f3b7fbeaf9c06618d376ac4bc", 13 | "zh:85f1fe3628954c35379cc05b895091ec8fe8ba0a5610bc9660492d5be65d4902", 14 | "zh:873fb64fca79695aa930cd868b41ac498809eb76bc3292e41460d916c6fa3538", 15 | "zh:8d3c5112a4abf14b42d769f78373e66f2c2f5f03a7e6544d80019a695bd9b198", 16 | "zh:93cbcfa38991965b976d1973bc528d666006b5247c3fda00c714d0f3a2b62d3e", 17 | "zh:b7710246637aee522a4ea4c1b4f0effb83b701546124ae90c8b4afb97ce03aba", 18 | "zh:e4e02fe946ccbe192b6bbc6bed5715cf68084f1faadc134ed99d5e732429d2ca", 19 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", 20 | "zh:fb6b1e4fb2d019d2740aa21b5ecd5f0609f562633a78604a96c14c94aff762b4", 21 | ] 22 | } 23 | 24 | provider "registry.terraform.io/hashicorp/google-beta" { 25 | version = "5.34.0" 26 | constraints = ">= 4.50.0, < 6.0.0" 27 | hashes = [ 28 | "h1:08e6reS//101sFAQCEPWg+eImpE4JeBktf4nNt8pEzg=", 29 | "zh:01619cfe684471dc88d470cf157f7adc659a2f6849346d6be2a71efe1cbd0250", 30 | "zh:1b6b2401862aaaf08819cf83b27a147957f0bcc1821a3b94a438788760cb65ad", 31 | "zh:30d3fbaa204dd1d197d01ed5385a5d325fd8d313a5fdcf7cdd80209f1740247f", 32 | "zh:461d084c0a0590785134218d57df39f34863a8977e4e925585eea085c86c97b5", 33 | "zh:534bc4652861bdcbe0451673269d326477781d70a9f03cae3b780d574f29f841", 34 | "zh:6e8abcd37a9609b05aab3529ccc3414b6d1258b124e58754b62f28fd4f3877a7", 35 | "zh:838a31873ce35e40e52ba0513aec5ff519159e99459829f0ea590eb62714801a", 36 | "zh:9387550c9e45e68c7ac5d6839a8f88e8e525ebf81a4c76847f7c05f13bf5dc19", 37 | "zh:997ac33e5d72f0aecfddd5235ba4dbe82b5bbaf811b801849e419942e204a12b", 38 | "zh:a66fbccde0dd854f764bf247576eaea4898966b6814db232fa45e789dc2ca014", 39 | "zh:d60efed82a54ff41a8f2380f83fefd9477616f49296e349b5a41fccb558fde08", 40 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", 41 | ] 42 | } 43 | 44 | provider "registry.terraform.io/hashicorp/local" { 45 | version = "2.5.1" 46 | hashes = [ 47 | "h1:8oTPe2VUL6E2d3OcrvqyjI4Nn/Y/UEQN26WLk5O/B0g=", 48 | "zh:0af29ce2b7b5712319bf6424cb58d13b852bf9a777011a545fac99c7fdcdf561", 49 | "zh:126063ea0d79dad1f68fa4e4d556793c0108ce278034f101d1dbbb2463924561", 50 | "zh:196bfb49086f22fd4db46033e01655b0e5e036a5582d250412cc690fa7995de5", 51 | "zh:37c92ec084d059d37d6cffdb683ccf68e3a5f8d2eb69dd73c8e43ad003ef8d24", 52 | "zh:4269f01a98513651ad66763c16b268f4c2da76cc892ccfd54b401fff6cc11667", 53 | "zh:51904350b9c728f963eef0c28f1d43e73d010333133eb7f30999a8fb6a0cc3d8", 54 | "zh:73a66611359b83d0c3fcba2984610273f7954002febb8a57242bbb86d967b635", 55 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", 56 | "zh:7ae387993a92bcc379063229b3cce8af7eaf082dd9306598fcd42352994d2de0", 57 | "zh:9e0f365f807b088646db6e4a8d4b188129d9ebdbcf2568c8ab33bddd1b82c867", 58 | "zh:b5263acbd8ae51c9cbffa79743fbcadcb7908057c87eb22fd9048268056efbc4", 59 | "zh:dfcd88ac5f13c0d04e24be00b686d069b4879cc4add1b7b1a8ae545783d97520", 60 | ] 61 | } 62 | 63 | provider "registry.terraform.io/hashicorp/random" { 64 | version = "3.6.2" 65 | constraints = ">= 2.1.0" 66 | hashes = [ 67 | "h1:wmG0QFjQ2OfyPy6BB7mQ57WtoZZGGV07uAPQeDmIrAE=", 68 | "zh:0ef01a4f81147b32c1bea3429974d4d104bbc4be2ba3cfa667031a8183ef88ec", 69 | "zh:1bcd2d8161e89e39886119965ef0f37fcce2da9c1aca34263dd3002ba05fcb53", 70 | "zh:37c75d15e9514556a5f4ed02e1548aaa95c0ecd6ff9af1119ac905144c70c114", 71 | "zh:4210550a767226976bc7e57d988b9ce48f4411fa8a60cd74a6b246baf7589dad", 72 | "zh:562007382520cd4baa7320f35e1370ffe84e46ed4e2071fdc7e4b1a9b1f8ae9b", 73 | "zh:5efb9da90f665e43f22c2e13e0ce48e86cae2d960aaf1abf721b497f32025916", 74 | "zh:6f71257a6b1218d02a573fc9bff0657410404fb2ef23bc66ae8cd968f98d5ff6", 75 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", 76 | "zh:9647e18f221380a85f2f0ab387c68fdafd58af6193a932417299cdcae4710150", 77 | "zh:bb6297ce412c3c2fa9fec726114e5e0508dd2638cad6a0cb433194930c97a544", 78 | "zh:f83e925ed73ff8a5ef6e3608ad9225baa5376446349572c2449c0c0b3cf184b7", 79 | "zh:fbef0781cb64de76b1df1ca11078aecba7800d82fd4a956302734999cfd9a4af", 80 | ] 81 | } 82 | -------------------------------------------------------------------------------- /scripts/gcp/cloudrun/README.md: -------------------------------------------------------------------------------- 1 | # Steps 2 | 3 | ## STEP 1: Prepare `terraform.tfvars` 4 | 5 | Look at the `variables.tf` and provide necessary variables and/or override defaults via `terraform.tfvars`. 6 | 7 | ## STEP 2: Set up project 8 | 9 | ``` 10 | $ cd terraform 11 | $ terraform plan 12 | $ terraform apply 13 | ``` 14 | 15 | `terraform apply` creates a `build-and-deploy.sh` which you need to run after `terraform apply` ends successfully. 16 | 17 | ## STEP 3: Deploy Cloud Run service 18 | 19 | ``` 20 | $ ./build-and-deploy.sh 21 | ``` 22 | -------------------------------------------------------------------------------- /scripts/gcp/cloudrun/build-and-deploy.sh.tmpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pushd ../../../ > /dev/null 4 | 5 | gcloud auth configure-docker --quiet \ 6 | ${REGION}-docker.pkg.dev 7 | 8 | docker build -t ${IMAGE} . 9 | 10 | docker push ${IMAGE} 11 | 12 | gcloud run deploy ${CLOUD_RUN_NAME} \ 13 | --image=${IMAGE} \ 14 | --no-allow-unauthenticated \ 15 | --port=8080 \ 16 | --service-account=${SA_EMAIL} \ 17 | --set-env-vars='CONFIG_FILE=/config/conf.toml' --set-env-vars='IAP_AUDIENCE=/projects/${PROJECT_NUMBER}/global/backendServices/${BACKEND_SERVICE_ID}' \ 18 | --set-secrets=/config/conf.toml=cloud-orchestrator-config:latest \ 19 | --ingress=internal-and-cloud-load-balancing \ 20 | --vpc-connector=${CONNECTOR_ID} \ 21 | --vpc-egress=private-ranges-only \ 22 | --region=${REGION} \ 23 | --project=${PROJECT_ID} 24 | 25 | gcloud run services add-iam-policy-binding ${CLOUD_RUN_NAME} \ 26 | --region=${REGION} \ 27 | --member=serviceAccount:service-${PROJECT_NUMBER}@gcp-sa-iap.iam.gserviceaccount.com \ 28 | --role=roles/run.invoker \ 29 | --project=${PROJECT_ID} 30 | 31 | cat << EOF > SETTING_FILE 32 | access_settings: 33 | oauth_settings: 34 | programmatic_clients: ["${OAUTH_CLIENT_ID}"] 35 | EOF 36 | 37 | gcloud iap settings set SETTING_FILE \ 38 | --project=${PROJECT_ID} \ 39 | --resource-type=iap_web \ 40 | --service=${BACKEND_SERVICE_NAME} 41 | 42 | popd > /dev/null 43 | -------------------------------------------------------------------------------- /scripts/gcp/cloudrun/conf.toml.tmpl: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google Inc. All rights reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Template to generate the service `conf.toml` file 16 | # 17 | # export PROJECT_ID="foo" 18 | # envsubst < conf.toml.tmpl > conf.toml 19 | 20 | WebStaticFilesPath = "web" 21 | 22 | [AccountManager] 23 | Type = "IAP" 24 | 25 | # TODO: This could be emtpy for gae vanilla aosp. 26 | [AccountManager.OAuth2] 27 | Provider = "Google" 28 | RedirectURL = "http://foo.bar/oauth2callback" 29 | 30 | [SecretManager] 31 | Type = "" 32 | 33 | [EncryptionService] 34 | Type = "Fake" 35 | 36 | [DatabaseService] 37 | Type = "InMemory" 38 | 39 | [InstanceManager] 40 | Type = "GCP" 41 | HostOrchestratorProtocol = "https" 42 | AllowSelfSignedHostSSLCertificate = true 43 | 44 | [InstanceManager.GCP] 45 | ProjectID = "${PROJECT_ID}" 46 | HostImageFamily = "projects/${PROJECT_ID}/global/images/family/cf-debian11-amd64" 47 | HostOrchestratorPort = 2443 48 | Network = "${NETWORK}" 49 | Subnetwork = "${SUBNETWORK}" 50 | UseExternalIP = ${USE_EXTERNAL_IP} 51 | 52 | [[WebRTC.IceServers]] 53 | URLs = ["stun:stun.l.google.com:19302"] 54 | -------------------------------------------------------------------------------- /scripts/gcp/cloudrun/variables.tf: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2024 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | variable "project_id" { 18 | type = string 19 | } 20 | 21 | variable "region" { 22 | description = "Location for load balancer and Cloud Run resources" 23 | type = string 24 | default = "europe-west3" 25 | } 26 | 27 | variable "domain" { 28 | description = "Domain name to run the load balancer on." 29 | type = string 30 | } 31 | 32 | variable "lb_name" { 33 | description = "Name for load balancer and associated resources" 34 | type = string 35 | default = "tf-cr-lb" 36 | } 37 | 38 | variable "oauth_support_email" { 39 | description = "eMail address displayed to users regarding questions about their consent" 40 | type = string 41 | } 42 | 43 | variable "artifact_repository_id" { 44 | description = "The name of the Artificat Repository" 45 | type = string 46 | default = "cloud-android-orchestration" 47 | } 48 | 49 | variable "service_accessors" { 50 | description = "List of principals which should be able to call the cloud orchestrator" 51 | type = set(string) 52 | default = [] 53 | } 54 | 55 | variable "cloud_run_name" { 56 | description = "The name of the Cloud Run service" 57 | type = string 58 | default = "cloud-orchestrator" 59 | } 60 | 61 | variable "serverless_connector_name" { 62 | description = "The name of the Serverless VPC Connector" 63 | type = string 64 | default = "co-vpc-connector" 65 | } 66 | 67 | variable "use_external_ip" { 68 | description = "Toggle to use external IPs in GCP" 69 | type = bool 70 | default = false 71 | } 72 | -------------------------------------------------------------------------------- /scripts/gcp/create_host_image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright (C) 2024 The Android Open Source Project 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # Create GCP image from https://github.com/google/android-cuttlefish host 18 | # images. 19 | # 20 | # Image Name Format: cf-debian11-amd64-- 21 | # 22 | # IMPORTANT!!! Artifact download URL only work for registered users (404 for guests) 23 | # https://github.com/actions/upload-artifact/issues/51 24 | 25 | set -e 26 | 27 | usage() { 28 | echo "usage: $0 -s head-commit-sha -t /path/to/github_auth_token.txt -p project-id" 29 | } 30 | 31 | commit_sha= 32 | github_auth_token_filename= 33 | project_id= 34 | 35 | while getopts "hs:t:p:" opt; do 36 | case "${opt}" in 37 | h) 38 | usage 39 | exit 0 40 | ;; 41 | s) 42 | commit_sha="${OPTARG}" 43 | ;; 44 | t) 45 | github_auth_token_filename="${OPTARG}" 46 | ;; 47 | p) 48 | project_id="${OPTARG}" 49 | ;; 50 | \?) 51 | usage; exit 1 52 | ;; 53 | esac 54 | done 55 | 56 | # Paginate until finding the relevant artifact entry 57 | artifact= 58 | url="https://api.github.com/repos/google/android-cuttlefish/actions/artifacts?per_page=100" 59 | while [[ -z "${artifact}" ]]; do 60 | res=$(curl --include -L \ 61 | -H "Accept: application/vnd.github+json" \ 62 | -H "Authorization: Bearer $(cat ${github_auth_token_filename})" \ 63 | -H "X-GitHub-Api-Version: 2022-11-28" \ 64 | "${url}" 2> /dev/null) 65 | json=$(echo "${res}" | sed -n '/^{$/,/^}$/p') 66 | artifact=$(echo "${json}" | jq ".artifacts[] | select(.workflow_run.head_sha == \"${commit_sha}\" and .name == \"image_gce_debian11_amd64\")") 67 | if [[ -z "${artifact}" ]]; then 68 | link=$(echo "${res}" | sed "/^link:/!d") 69 | if [[ ${link} == *"rel=\"next\""* ]]; then 70 | url=$(echo "${link}" | grep -Eo "; rel=\"next\"" | grep -Eo "https://.*page=[0-9]+") 71 | else 72 | echo "Artifact not found for commit: ${commit_sha}" 73 | exit 1 74 | fi 75 | fi 76 | done 77 | 78 | bucket_name="${project_id}-cf-host-image-upload" 79 | 80 | updated_at=$(echo "${artifact}" | jq -r ".updated_at") 81 | date_suffix=$(date -u -d ${updated_at} +"%Y%m%d") 82 | name=cf-debian11-amd64-${date_suffix}-${commit_sha:0:7} 83 | 84 | function cleanup { 85 | rm "image.zip" 2> /dev/null 86 | rm "image.tar.gz" 2> /dev/null 87 | gcloud storage rm --recursive gs://${bucket_name} 88 | } 89 | 90 | trap cleanup EXIT 91 | 92 | echo "Downloading artifact ..." 93 | download_url=$(echo $artifact | jq -r ".archive_download_url") 94 | curl -L \ 95 | -H "Accept: application/vnd.github+json" \ 96 | -H "Authorization: Bearer $(cat ${github_auth_token_filename})" \ 97 | -H "X-GitHub-Api-Version: 2022-11-28" \ 98 | --output image.zip \ 99 | ${download_url} 100 | 101 | unzip image.zip 102 | 103 | gcloud services enable compute.googleapis.com --project="${project_id}" 104 | 105 | gcloud storage buckets create gs://${bucket_name} --location="us-east1" --project="${project_id}" 106 | gcloud storage cp image.tar.gz gs://${bucket_name}/${name}.tar.gz 107 | 108 | echo "Creating image ..." 109 | gcloud compute images create ${name} \ 110 | --source-uri gs://${bucket_name}/${name}.tar.gz \ 111 | --project ${project_id} \ 112 | --family "cf-debian11-amd64" 113 | -------------------------------------------------------------------------------- /scripts/gcp/create_oidc_token_service_account_key_file.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2024 Google Inc. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # Generates an OIDC token from a local service account key file. 18 | # Adapted from https://cloud.google.com/iap/docs/authentication-howto#obtaining_an_oidc_token_from_a_local_service_account_key_file 19 | 20 | set -euo pipefail 21 | 22 | usage() { 23 | echo "usage: $0 -k /path/to/key -c iap-client-id" 24 | } 25 | 26 | key_file_path= 27 | iap_client_id= 28 | 29 | while getopts ":hk:c:" opt; do 30 | case "${opt}" in 31 | h) 32 | usage 33 | exit 0 34 | ;; 35 | k) 36 | key_file_path="${OPTARG}" 37 | ;; 38 | c) 39 | iap_client_id="${OPTARG}" 40 | ;; 41 | \?) 42 | echo "Invalid option: ${OPTARG}" >&2 43 | usage 44 | exit 1 45 | ;; 46 | :) 47 | echo "Invalid option: ${OPTARG} requires an argument" >&2 48 | usage 49 | exit 1 50 | ;; 51 | esac 52 | done 53 | 54 | get_token() { 55 | # Get the bearer token in exchange for the service account credentials. 56 | local service_account_key_file_path="${1}" 57 | local iap_client_id="${2}" 58 | 59 | local iam_scope="https://www.googleapis.com/auth/iam" 60 | local oauth_token_uri="https://www.googleapis.com/oauth2/v4/token" 61 | 62 | local private_key_id="$(cat "${service_account_key_file_path}" | jq -r '.private_key_id')" 63 | local client_email="$(cat "${service_account_key_file_path}" | jq -r '.client_email')" 64 | local private_key="$(cat "${service_account_key_file_path}" | jq -r '.private_key')" 65 | local issued_at="$(date +%s)" 66 | local expires_at="$((issued_at + 600))" 67 | local header="{'alg':'RS256','typ':'JWT','kid':'${private_key_id}'}" 68 | local header_base64="$(echo "${header}" | base64)" 69 | local payload="{'iss':'${client_email}','aud':'${oauth_token_uri}','exp':${expires_at},'iat':${issued_at},'sub':'${client_email}','target_audience':'${iap_client_id}'}" 70 | local payload_base64="$(echo "${payload}" | base64)" 71 | local signature_base64="$(printf %s "${header_base64}.${payload_base64}" | openssl dgst -binary -sha256 -sign <(printf '%s\n' "${private_key}") | base64)" 72 | local assertion="${header_base64}.${payload_base64}.${signature_base64}" 73 | local token_payload="$(curl -s \ 74 | --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \ 75 | --data-urlencode "assertion=${assertion}" \ 76 | https://www.googleapis.com/oauth2/v4/token)" 77 | local bearer_id_token="$(echo "${token_payload}" | jq -r '.id_token')" 78 | echo "${bearer_id_token}" 79 | } 80 | 81 | token=$(get_token "${key_file_path}" "${iap_client_id}") 82 | 83 | printf ${token} 84 | 85 | -------------------------------------------------------------------------------- /scripts/goutil: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -x 5 | 6 | cd $1 7 | shift 8 | 9 | # Override these variables to make go not depend on HOME 10 | mkdir -p /tmp/go 11 | export GOPATH=/tmp/go 12 | export GOCACHE=/tmp/go/go-build 13 | 14 | GOBIN=go 15 | if ! command -v go &> /dev/null 16 | then 17 | GOBIN=/usr/lib/go-1.13/bin/go 18 | fi 19 | 20 | $GOBIN "$@" 21 | -------------------------------------------------------------------------------- /scripts/on-premises/single-server/README.md: -------------------------------------------------------------------------------- 1 | # Activate cloud orchestrator at on-premise server 2 | 3 | This page describes how to run cloud orchestrator at on-premise server, which 4 | manages docker instances containing the host orchestrator inside. 5 | 6 | Note that this is under development, some features may be broken yet. 7 | Please let us know if you faced at any bugs. 8 | 9 | ## Try cloud orchestrator 10 | 11 | Currently we're hosting docker images and its configuration files in Artifact 12 | Registry. 13 | Please execute the commands below if you want to download and run the cloud 14 | orchestrator. 15 | 16 | Also, please choose one location among `us`, `europe`, or `asia`. 17 | It's available to download artifacts from any location, but download latency is 18 | different based on your location. 19 | 20 | ```bash 21 | DOWNLOAD_LOCATION=us # Choose one among us, europe, or asia. 22 | docker pull $DOWNLOAD_LOCATION-docker.pkg.dev/android-cuttlefish-artifacts/cuttlefish-orchestration/cuttlefish-cloud-orchestrator 23 | wget -O conf.toml https://artifactregistry.googleapis.com/download/v1/projects/android-cuttlefish-artifacts/locations/$DOWNLOAD_LOCATION/repositories/cloud-orchestrator-config/files/on-premise-single-server:main:conf.toml:download?alt=media 24 | docker run \ 25 | -p 8080:8080 \ 26 | -e CONFIG_FILE="/conf.toml" \ 27 | -v $PWD/conf.toml:/conf.toml \ 28 | -v /var/run/docker.sock:/var/run/docker.sock \ 29 | -t $DOWNLOAD_LOCATION-docker.pkg.dev/android-cuttlefish-artifacts/cuttlefish-orchestration/cuttlefish-cloud-orchestrator:latest 30 | ``` 31 | 32 | To enable TURN server support for WebRTC peer-to-peer connections, configure 33 | your TURN server settings in the `conf.toml` file before starting the cloud 34 | orchestrator. 35 | See the example below. 36 | ``` 37 | [[WebRTC.IceServers]] 38 | URLs = ["turn:localhost:3478"] 39 | Username = "username" 40 | Credential = "credential" 41 | ``` 42 | 43 | If there's a firewall which blocks accessing cloud orchestrator with HTTP/HTTPS 44 | requests, please try using SOCKS5 proxy. Establishing SOCKS5 proxy by creating 45 | SSH dynamic port forwarding is available with following command. 46 | ```bash 47 | ssh -D ${SOCKS5_PORT} -q -C -N ${USERNAME}@${CLOUD_ORCHESTRATOR_IPv4_ADDRESS} 48 | ``` 49 | ## Use cloud orchestrator by cvdr 50 | 51 | The config file for `cvdr` is located at 52 | [scripts/on-premises/single-server/cvdr.toml](cvdr.toml). 53 | Please follow the steps at [cvdr.md](/docs/cvdr.md), to get started with `cvdr`. 54 | 55 | ## Manually build and run cloud orchestrator 56 | 57 | The config file for cloud orchestrator is at 58 | [scripts/on-premises/single-server/conf.toml](conf.toml). 59 | Please follow the steps at [cloud_orchestrator.md](/docs/cloud_orchestrator.md), 60 | to build and run cloud orchestrator. 61 | 62 | Also, you may need to prepare another docker image containing the host 63 | orchestrator inside, unlike steps in 64 | [Try cloud orchestrator](#try-cloud-orchestrator). 65 | Please follow the docker part of 66 | [README.md](https://github.com/google/android-cuttlefish/blob/main/README.md#docker) 67 | in `google/android-cuttlefish` github repository, and check if proper docker 68 | image exists via `docker image list`. 69 | -------------------------------------------------------------------------------- /scripts/on-premises/single-server/conf.toml: -------------------------------------------------------------------------------- 1 | WebStaticFilesPath = "web" 2 | 3 | # List of allowed origins 4 | # e.g. "https://localhost:8080" 5 | CORSAllowedOrigins = [] 6 | 7 | [AccountManager] 8 | Type = "username-only" 9 | 10 | [AccountManager.OAuth2] 11 | Provider = "Google" 12 | RedirectURL = "http://localhost:8080/oauth2callback" 13 | 14 | [SecretManager] 15 | Type = "" 16 | 17 | [EncryptionService] 18 | Type = "Fake" 19 | 20 | [DatabaseService] 21 | Type = "InMemory" 22 | 23 | [InstanceManager] 24 | Type = "docker" 25 | HostOrchestratorProtocol = "http" 26 | AllowSelfSignedHostSSLCertificate = true 27 | 28 | [InstanceManager.Docker] 29 | DockerImageName = "cuttlefish-orchestration:latest" 30 | HostOrchestratorPort = 2080 31 | 32 | [[WebRTC.IceServers]] 33 | URLs = ["stun:stun.l.google.com:19302"] 34 | -------------------------------------------------------------------------------- /scripts/on-premises/single-server/cvdr.toml: -------------------------------------------------------------------------------- 1 | # Configuration for the cvdr program when the Cloud Android Orchestration 2 | # service uses DockerInstanceManager. 3 | 4 | # Default service 5 | SystemDefaultService = "dockerIM" 6 | 7 | [Services.dockerIM] 8 | 9 | # ServiceURL should be the full path to the service API without including the 10 | # api version. 11 | # Please set `CLOUD_ORCHESTRATOR_IP_ADDRESS` according to your cloud 12 | # orchestrator deployment. Typically this is the IP address of your server. 13 | # ServiceURL = "http://${CLOUD_ORCHESTRATOR_IP_ADDRESS}:8080" 14 | ServiceURL = "http://localhost:8080" 15 | 16 | # The zone where the users' host VMs will be created. 17 | Zone = "local" 18 | 19 | # The proxy URL. 20 | # When this configuration is required, please uncomment below configuration and 21 | # set `SOCKS5_PORT` according to your SSH dynamic port forwarding. 22 | # Proxy = "socks5://localhost:${SOCKS5_PORT}" 23 | 24 | # The configuration for cvdr to get your username from the unix system. 25 | # This is required when the Cloud Android Orchestration service uses 26 | # UsernameOnlyAccountManager. 27 | Authn = { 28 | HTTPBasicAuthn = { 29 | UsernameSrc = "unix" 30 | } 31 | } 32 | 33 | # The host config for the Cloud Android Orchestration service. 34 | Host = {} 35 | 36 | # Agent name used for ADB connection. 37 | ConnectAgent = "websocket" 38 | -------------------------------------------------------------------------------- /web/page0/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /web/page0/.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /web/page0/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/gts/" 3 | } 4 | -------------------------------------------------------------------------------- /web/page0/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | *.js 9 | 10 | # Node 11 | /node_modules 12 | npm-debug.log 13 | yarn-error.log 14 | 15 | # IDEs and editors 16 | .idea/ 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # Visual Studio Code 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | .history/* 31 | 32 | # Miscellaneous 33 | /.angular/cache 34 | .sass-cache/ 35 | /connect.lock 36 | /coverage 37 | /libpeerconnection.log 38 | testem.log 39 | /typings 40 | 41 | # System files 42 | .DS_Store 43 | Thumbs.db 44 | 45 | 46 | # Python cache files 47 | __pycache__ 48 | -------------------------------------------------------------------------------- /web/page0/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('gts/.prettierrc.json') 3 | } 4 | -------------------------------------------------------------------------------- /web/page0/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /web/page0/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /web/page0/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /web/page0/README.md: -------------------------------------------------------------------------------- 1 | # Page0 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 16.1.4. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 28 | -------------------------------------------------------------------------------- /web/page0/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "page0", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/animations": "^19.2.1", 14 | "@angular/cdk": "^19.2.2", 15 | "@angular/common": "^19.2.0", 16 | "@angular/compiler": "^19.2.0", 17 | "@angular/core": "^19.2.0", 18 | "@angular/forms": "^19.2.0", 19 | "@angular/material": "^19.2.2", 20 | "@angular/platform-browser": "^19.2.0", 21 | "@angular/platform-browser-dynamic": "^19.2.0", 22 | "@angular/router": "^19.2.0", 23 | "@types/node": "^22.13.10", 24 | "rxjs": "~7.8.0", 25 | "tslib": "^2.3.0", 26 | "zone.js": "~0.15.0" 27 | }, 28 | "devDependencies": { 29 | "@angular-devkit/build-angular": "^20.0.1", 30 | "@angular/cli": "^19.2.1", 31 | "@angular/compiler-cli": "^19.2.0", 32 | "@types/jasmine": "~5.1.0", 33 | "jasmine-core": "~5.6.0", 34 | "karma": "~6.4.0", 35 | "karma-chrome-launcher": "~3.2.0", 36 | "karma-coverage": "~2.2.0", 37 | "karma-jasmine": "~5.1.0", 38 | "karma-jasmine-html-reporter": "~2.1.0", 39 | "gts": "^6.0.2", 40 | "typescript": "~5.7.2" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /web/page0/src/app/active-env-pane/active-env-pane.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Active Environments

5 |
6 | 7 |
8 | 11 | 14 |
15 |
16 | 17 | 18 | 19 | 20 |
21 |
22 |
  • 23 | 24 |
  • 25 |
    26 |
    27 | 28 | 29 |

    Nothing here yet ...

    30 |
    31 |
    32 | -------------------------------------------------------------------------------- /web/page0/src/app/active-env-pane/active-env-pane.component.scss: -------------------------------------------------------------------------------- 1 | .env-pane { 2 | background-color: white; 3 | padding: 20px; 4 | max-width: 480px; 5 | } 6 | 7 | #env-pane-header-title { 8 | margin-right: 48px; 9 | } 10 | 11 | .env-pane-header { 12 | display: flex; 13 | flex-direction: row; 14 | } 15 | 16 | .view-action { 17 | padding: 0px 12px; 18 | margin-right: 8px; 19 | } 20 | 21 | li { 22 | list-style: none; 23 | } 24 | 25 | .env-pane-header-actions { 26 | display: flex; 27 | flex-direction: row; 28 | } 29 | 30 | mat-divider { 31 | padding-bottom: 20px; 32 | } -------------------------------------------------------------------------------- /web/page0/src/app/active-env-pane/active-env-pane.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, inject} from '@angular/core'; 2 | import { 3 | envCardListSelector, 4 | runtimesLoadStatusSelector, 5 | } from 'src/app/store/selectors'; 6 | import {Store} from 'src/app/store/store'; 7 | import {RefreshService} from '../refresh.service'; 8 | import {RuntimeViewStatus} from 'src/app/interface/runtime-interface'; 9 | 10 | @Component({ 11 | standalone: false, 12 | selector: 'app-active-env-pane', 13 | templateUrl: './active-env-pane.component.html', 14 | styleUrls: ['./active-env-pane.component.scss'], 15 | }) 16 | export class ActiveEnvPaneComponent { 17 | private refreshService = inject(RefreshService); 18 | private store = inject(Store); 19 | 20 | envs$ = this.store.select(envCardListSelector); 21 | status$ = this.store.select(runtimesLoadStatusSelector); 22 | 23 | onClickRefresh() { 24 | this.refreshService.refresh(); 25 | } 26 | 27 | showProgressBar(status: RuntimeViewStatus | null) { 28 | return ( 29 | status === RuntimeViewStatus.refreshing || 30 | status === RuntimeViewStatus.initializing 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /web/page0/src/app/api.service.spec.ts: -------------------------------------------------------------------------------- 1 | import {TestBed} from '@angular/core/testing'; 2 | 3 | import {ApiService} from './api.service'; 4 | 5 | describe('ApiService', () => { 6 | let service: ApiService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(ApiService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /web/page0/src/app/api.service.ts: -------------------------------------------------------------------------------- 1 | import {HttpClient} from '@angular/common/http'; 2 | import {Injectable} from '@angular/core'; 3 | import { 4 | CreateHostRequest, 5 | ListHostsResponse, 6 | ListZonesResponse, 7 | Operation, 8 | RuntimeConfig, 9 | } from 'src/app/interface/cloud-orchestrator.dto'; 10 | import {ListCVDsResponse} from 'src/app/interface/host-orchestrator.dto'; 11 | 12 | @Injectable({ 13 | providedIn: 'root', 14 | }) 15 | export class ApiService { 16 | constructor(private httpClient: HttpClient) {} 17 | 18 | // Global Routes 19 | getRuntimeConfig(runtimeUrl: string) { 20 | return this.httpClient.get(`${runtimeUrl}/v1/config`); 21 | } 22 | 23 | listZones(runtimeUrl: string) { 24 | return this.httpClient.get(`${runtimeUrl}/v1/zones`); 25 | } 26 | 27 | // Instance Manager Routes 28 | createHost( 29 | runtimeUrl: string, 30 | zone: string, 31 | createHostRequest: CreateHostRequest 32 | ) { 33 | return this.httpClient.post( 34 | `${runtimeUrl}/v1/zones/${zone}/hosts`, 35 | createHostRequest 36 | ); 37 | } 38 | 39 | listHosts(runtimeUrl: string, zone: string) { 40 | return this.httpClient.get( 41 | `${runtimeUrl}/v1/zones/${zone}/hosts` 42 | ); 43 | } 44 | 45 | deleteHost(hostUrl: string) { 46 | return this.httpClient.delete(`${hostUrl}`); 47 | } 48 | 49 | // Host Orchestrator Proxy Routes 50 | listGroups(hostUrl: string) { 51 | return this.httpClient.get(`${hostUrl}/groups`); 52 | } 53 | 54 | createGroup(hostUrl: string, config: object) { 55 | return this.httpClient.post(`${hostUrl}/cvds`, { 56 | env_config: config, 57 | }); 58 | } 59 | 60 | deleteGroup(hostUrl: string, groupName: string) { 61 | return this.httpClient.delete(`${hostUrl}/groups/${groupName}`); 62 | } 63 | 64 | listDevicesByGroup(hostUrl: string, groupName: string) { 65 | return this.httpClient.get<{device_id: string; group_id: string}[]>( 66 | `${hostUrl}/devices?groupId=${groupName}` 67 | ); 68 | } 69 | 70 | listCvds(hostUrl: string) { 71 | return this.httpClient.get(`${hostUrl}/cvds`); 72 | } 73 | 74 | wait(waitUrl: string) { 75 | return this.httpClient.post(`${waitUrl}/:wait`, {}); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /web/page0/src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 |

    Cloud Android

    4 | 5 |
    6 | 10 | 11 | 15 |
    16 |
    17 | 18 | 19 | 20 |
    21 | 22 |
    23 |
    24 | {{ version }} 25 |
    26 |
    27 |
    28 |
    -------------------------------------------------------------------------------- /web/page0/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | position: absolute; 5 | top: 0; 6 | bottom: 0; 7 | left: 0; 8 | right: 0; 9 | background: #2a2a2a; 10 | color: #eee; 11 | } 12 | 13 | .page { 14 | padding: 20px; 15 | } 16 | 17 | .cloud-android-toolbar { 18 | background: #073042; 19 | color: #eee; 20 | box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.5); 21 | text-align: center; 22 | } 23 | 24 | .toolbar-actions { 25 | position: absolute; 26 | right: 0; 27 | margin-right: 20px; 28 | } 29 | 30 | .cloud-android-title { 31 | margin: 0 auto; 32 | } 33 | 34 | .drawer-container { 35 | flex: 1; 36 | } 37 | 38 | .device-view { 39 | margin: 0 auto; 40 | font-size: 200%; 41 | width: 250px; 42 | background: rgba(245, 245, 245, 0.8); 43 | color: #073042; 44 | filter: drop-shadow(4px 4px 10px rgba(0, 0, 0, 0.25)); 45 | border-radius: 0px 16px 16px 0px; 46 | } 47 | 48 | .version-info { 49 | position: fixed; 50 | bottom: 0; 51 | right: 0; 52 | margin: 0px 5px 5px 0px; 53 | z-index: 1; 54 | } -------------------------------------------------------------------------------- /web/page0/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Injectable} from '@angular/core'; 2 | import {BUILD_VERSION} from '../version'; 3 | import {RefreshService} from './refresh.service'; 4 | 5 | @Injectable() 6 | @Component({ 7 | standalone: false, 8 | selector: 'app-root', 9 | templateUrl: './app.component.html', 10 | styleUrls: ['./app.component.scss'], 11 | }) 12 | export class AppComponent { 13 | readonly version = BUILD_VERSION; 14 | constructor(private refreshService: RefreshService) { 15 | this.refreshService.refresh(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /web/page0/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {BrowserModule} from '@angular/platform-browser'; 3 | import {FormsModule, ReactiveFormsModule} from '@angular/forms'; 4 | 5 | import {AppComponent} from './app.component'; 6 | import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; 7 | import {HttpClientModule} from '@angular/common/http'; 8 | import {MatButtonModule} from '@angular/material/button'; 9 | import {MatCardModule} from '@angular/material/card'; 10 | import {MatCheckboxModule} from '@angular/material/checkbox'; 11 | import {MatIconModule} from '@angular/material/icon'; 12 | import {MatSidenavModule} from '@angular/material/sidenav'; 13 | import {MatSlideToggleModule} from '@angular/material/slide-toggle'; 14 | import {MatToolbarModule} from '@angular/material/toolbar'; 15 | import {MatTooltipModule} from '@angular/material/tooltip'; 16 | import {MatDividerModule} from '@angular/material/divider'; 17 | import {MatInputModule} from '@angular/material/input'; 18 | import { 19 | MatFormFieldModule, 20 | MAT_FORM_FIELD_DEFAULT_OPTIONS, 21 | } from '@angular/material/form-field'; 22 | import {MatSelectModule} from '@angular/material/select'; 23 | import {MatListModule} from '@angular/material/list'; 24 | import {MatExpansionModule} from '@angular/material/expansion'; 25 | 26 | import {RouterModule} from '@angular/router'; 27 | 28 | import {EnvListViewComponent} from './env-list-view/env-list-view.component'; 29 | import {ActiveEnvPaneComponent} from './active-env-pane/active-env-pane.component'; 30 | import {EnvCardComponent} from './env-card/env-card.component'; 31 | import {CreateEnvViewComponent} from './create-env-view/create-env-view.component'; 32 | import {NgIf} from '@angular/common'; 33 | import {RegisterRuntimeViewComponent} from './register-runtime-view/register-runtime-view.component'; 34 | import {CreateHostViewComponent} from './create-host-view/create-host-view.component'; 35 | import {ListRuntimeViewComponent} from './list-runtime-view/list-runtime-view.component'; 36 | import {RuntimeCardComponent} from './runtime-card/runtime-card.component'; 37 | import {MatProgressBarModule} from '@angular/material/progress-bar'; 38 | import {MatSnackBarModule} from '@angular/material/snack-bar'; 39 | import {DeviceFormComponent} from './device-form/device-form.component'; 40 | import {SafeUrlPipe} from './safe-url.pipe'; 41 | import {HTTP_INTERCEPTOR_PROVIDERS} from '../http_interceptors'; 42 | 43 | @NgModule({ 44 | declarations: [ 45 | AppComponent, 46 | EnvListViewComponent, 47 | ActiveEnvPaneComponent, 48 | EnvCardComponent, 49 | CreateEnvViewComponent, 50 | RegisterRuntimeViewComponent, 51 | CreateHostViewComponent, 52 | ListRuntimeViewComponent, 53 | RuntimeCardComponent, 54 | DeviceFormComponent, 55 | SafeUrlPipe, 56 | ], 57 | imports: [ 58 | BrowserModule, 59 | BrowserAnimationsModule, 60 | MatButtonModule, 61 | MatCardModule, 62 | MatCheckboxModule, 63 | MatIconModule, 64 | MatSidenavModule, 65 | MatSlideToggleModule, 66 | MatToolbarModule, 67 | MatTooltipModule, 68 | MatDividerModule, 69 | MatInputModule, 70 | MatFormFieldModule, 71 | MatSelectModule, 72 | MatListModule, 73 | MatExpansionModule, 74 | MatProgressBarModule, 75 | NgIf, 76 | ReactiveFormsModule, 77 | FormsModule, 78 | HttpClientModule, 79 | MatSnackBarModule, 80 | RouterModule.forRoot([ 81 | {path: 'create-env', component: CreateEnvViewComponent}, 82 | {path: '', component: EnvListViewComponent}, 83 | {path: 'list-runtime', component: ListRuntimeViewComponent}, 84 | {path: 'create-host', component: CreateHostViewComponent}, 85 | {path: 'register-runtime', component: RegisterRuntimeViewComponent}, 86 | ]), 87 | ], 88 | providers: [ 89 | HTTP_INTERCEPTOR_PROVIDERS, 90 | { 91 | provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, 92 | useValue: {appearance: 'outline'}, 93 | }, 94 | ], 95 | bootstrap: [AppComponent], 96 | }) 97 | export class AppModule {} 98 | -------------------------------------------------------------------------------- /web/page0/src/app/create-env-view/create-env-view.component.scss: -------------------------------------------------------------------------------- 1 | .env-view { 2 | background-color: white; 3 | padding: 20px; 4 | padding-bottom: 255px; 5 | } 6 | 7 | .full-width-field { 8 | width: 100%; 9 | } 10 | 11 | .flex-field { 12 | flex: 1; 13 | margin-right: 10px; 14 | } 15 | 16 | .flex-field:last-child { 17 | flex: 1; 18 | margin-right: 0; 19 | } 20 | 21 | .host-setting { 22 | display: flex; 23 | flex-direction: row; 24 | justify-content: space-between; 25 | } 26 | 27 | .footbar { 28 | padding: 30px; 29 | position: fixed; 30 | bottom: 0; 31 | left: 0; 32 | height: 100px; 33 | background-color: whitesmoke; 34 | border-top-right-radius: 20px; 35 | border-top-left-radius: 20px; 36 | width: 100%; 37 | z-index: 1; 38 | } 39 | 40 | .footbar-button { 41 | font-size: large; 42 | margin-right: 20px; 43 | } 44 | 45 | mat-divider { 46 | margin-bottom: 10px; 47 | } 48 | 49 | li { 50 | list-style: none; 51 | } 52 | 53 | .google-symbols {} 54 | 55 | .env-config-container { 56 | display: flex; 57 | } 58 | 59 | .env-config-setting { 60 | flex: 1; 61 | padding-right: 30px; 62 | } 63 | 64 | #canonical-config-field { 65 | min-height: 600px; 66 | } 67 | -------------------------------------------------------------------------------- /web/page0/src/app/create-env-view/create-env-view.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, inject} from '@angular/core'; 2 | import {MatSnackBar} from '@angular/material/snack-bar'; 3 | import {Router} from '@angular/router'; 4 | import {BehaviorSubject} from 'rxjs'; 5 | import {map, startWith} from 'rxjs/operators'; 6 | import {EnvFormService} from '../env-form.service'; 7 | import {EnvService} from '../env.service'; 8 | import {ResultType} from '../interface/result-interface'; 9 | import {validRuntimeListSelector} from '../store/selectors'; 10 | import {Store} from '../store/store'; 11 | import {AUTO_CREATE_HOST} from '../utils'; 12 | @Component({ 13 | standalone: false, 14 | selector: 'app-create-env-view', 15 | templateUrl: './create-env-view.component.html', 16 | styleUrls: ['./create-env-view.component.scss'], 17 | }) 18 | export class CreateEnvViewComponent { 19 | private router = inject(Router); 20 | private snackBar = inject(MatSnackBar); 21 | private envService = inject(EnvService); 22 | private envFormService = inject(EnvFormService); 23 | private store = inject(Store); 24 | envForm = this.envFormService.getEnvForm(); 25 | runtimes$ = this.store 26 | .select(validRuntimeListSelector) 27 | .pipe(map(runtimes => runtimes.map(runtime => runtime.alias))); 28 | zones$ = this.envFormService.getZones$(); 29 | hosts$ = this.envFormService.getHosts$(); 30 | status$ = new BehaviorSubject('done'); 31 | hint$ = this.envForm.controls.host.valueChanges.pipe(startWith(this.envForm.controls.host.value), map(host => { 32 | if (host === AUTO_CREATE_HOST) { 33 | return 'Auto Create may not be completed if you leave Page 0'; 34 | } 35 | return ''; 36 | })); 37 | 38 | autoCreateHostToken = AUTO_CREATE_HOST; 39 | 40 | showProgressBar(status: string | null) { 41 | return status === 'sending create request'; 42 | } 43 | 44 | onClickAddDevice() { 45 | this.envFormService.addDevice(); 46 | } 47 | 48 | onClickRegisterRuntime() { 49 | this.router.navigate(['/register-runtime'], { 50 | queryParams: { 51 | previousUrl: 'create-env', 52 | }, 53 | }); 54 | } 55 | 56 | onClickCreateHost() { 57 | this.router.navigate(['/create-host'], { 58 | queryParams: { 59 | previousUrl: 'create-env', 60 | runtime: this.envForm.value.runtime, 61 | }, 62 | }); 63 | } 64 | 65 | onSubmit() { 66 | const {runtime, zone, host, canonicalConfig} = this.envForm.value; 67 | 68 | this.status$.next('sending create request'); 69 | this.envService.createEnv(runtime, zone, host, canonicalConfig).subscribe({ 70 | next: result => { 71 | if (result.type === ResultType.waitStarted) { 72 | this.status$.next('done'); 73 | this.snackBar.dismiss(); 74 | this.router.navigate(['/']); 75 | this.envFormService.clearForm(); 76 | } 77 | }, 78 | error: error => { 79 | this.status$.next('error'); 80 | this.snackBar.open(error.message, 'Dismiss'); 81 | }, 82 | }); 83 | } 84 | 85 | onCancel() { 86 | this.status$.next('done'); 87 | this.router.navigate(['/']); 88 | this.envFormService.clearForm(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /web/page0/src/app/create-host-view/create-host-view.component.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |
    5 |

    New Host

    6 |
    7 |
    8 | 9 | 10 | 11 |
    12 |
    13 | 14 | Runtime 15 | 16 | 17 | 18 | 19 | Zone 20 | 21 | {{ zone }} 22 | 23 | 24 |
    25 | 26 |
    27 | 28 | Machine Type 29 | 30 | 31 | 32 | Min CPU Platform 33 | 34 | 35 |
    36 |
    37 | 38 | 39 |
    40 | 41 |
    42 | 43 | 44 | 47 | 50 |
    51 |
    52 | -------------------------------------------------------------------------------- /web/page0/src/app/create-host-view/create-host-view.component.scss: -------------------------------------------------------------------------------- 1 | .register-runtime-view { 2 | background-color: white; 3 | padding: 20px; 4 | padding-bottom: 255px; 5 | // height: 100%; 6 | min-height: 680px; 7 | 8 | } 9 | 10 | .full-width-field { 11 | width: 100%; 12 | } 13 | 14 | 15 | .flex-field { 16 | flex: 1; 17 | margin-right: 10px; 18 | } 19 | 20 | .flex-field:last-child { 21 | flex: 1; 22 | margin-right: 0; 23 | } 24 | 25 | .flex-container { 26 | display: flex; 27 | flex-direction: row; 28 | justify-content: space-between; 29 | } 30 | 31 | mat-divider { 32 | margin-bottom: 10px; 33 | } 34 | 35 | .footbar { 36 | padding: 30px; 37 | position: fixed; 38 | bottom: 0; 39 | left: 0; 40 | height: 100px; 41 | background-color: whitesmoke; 42 | border-top-right-radius: 20px; 43 | border-top-left-radius: 20px; 44 | width: 100%; 45 | z-index: 1; 46 | } 47 | 48 | .footbar-button { 49 | font-size: large; 50 | margin-right: 20px; 51 | } -------------------------------------------------------------------------------- /web/page0/src/app/device-form/device-form.component.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    # {{ idx + 1 }}
    4 | 5 | Device ID 6 | 7 | 8 | 9 | 12 | 13 | 16 |
    17 | 18 |
    19 |
    20 |
    Build Details
    21 | 22 | Branch / Build ID 23 | 24 | 25 | 26 | Target 27 | 28 | 29 |
    30 |
    31 |
    -------------------------------------------------------------------------------- /web/page0/src/app/device-form/device-form.component.scss: -------------------------------------------------------------------------------- 1 | .device-id-setting { 2 | display: flex; 3 | flex-direction: row; 4 | } 5 | 6 | .device-header-item { 7 | margin-right: 20px; 8 | font-size: large; 9 | height: fit-content; 10 | align-self: center; 11 | margin-bottom: 20px; 12 | width: 40px; 13 | } 14 | 15 | .device-id { 16 | flex: 1; 17 | } 18 | 19 | .device-setting { 20 | display: flex; 21 | flex-direction: row; 22 | justify-content: space-between; 23 | } 24 | 25 | .device-setting-title { 26 | font-size: medium; 27 | margin-bottom: 20px; 28 | } 29 | 30 | .device-setting-option { 31 | display: flex; 32 | flex-direction: column; 33 | flex: 1; 34 | margin-right: 10px; 35 | } 36 | 37 | .device-setting-option:last-child { 38 | margin-right: 0px; 39 | } -------------------------------------------------------------------------------- /web/page0/src/app/device-form/device-form.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input} from '@angular/core'; 2 | import {FormGroup} from '@angular/forms'; 3 | import {EnvFormService} from '../env-form.service'; 4 | 5 | @Component({ 6 | standalone: false, 7 | selector: 'app-device-form', 8 | templateUrl: './device-form.component.html', 9 | styleUrls: ['./device-form.component.scss'], 10 | }) 11 | export class DeviceFormComponent { 12 | @Input() form!: FormGroup; 13 | @Input() idx!: number; 14 | 15 | constructor(private envFormService: EnvFormService) {} 16 | 17 | onClickDeleteDevice() { 18 | this.envFormService.deleteDevice(this.idx); 19 | } 20 | 21 | onClickDuplicateDevice() { 22 | this.envFormService.duplicateDevice(this.idx); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web/page0/src/app/device.service.spec.ts: -------------------------------------------------------------------------------- 1 | import {TestBed} from '@angular/core/testing'; 2 | 3 | import {DeviceService} from './device.service'; 4 | 5 | describe('DeviceService', () => { 6 | let service: DeviceService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(DeviceService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /web/page0/src/app/device.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root', 5 | }) 6 | export class DeviceService {} 7 | -------------------------------------------------------------------------------- /web/page0/src/app/env-card/env-card.component.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 |
    4 | 13 | 14 | 23 |
    24 | 25 | 26 | Runtime: {{ env.runtimeAlias }} 27 | 28 | 29 | Host: {{ env.hostUrl }} 30 | 31 | Group ID: {{ env.groupName }} 32 | 33 | 34 | Devices: 35 |
      36 |
    • 37 | {{ device.deviceId }} 38 |
    • 39 |
    40 |
    41 | 42 |
    43 | 44 | 55 |
    56 |
    57 | -------------------------------------------------------------------------------- /web/page0/src/app/env-card/env-card.component.scss: -------------------------------------------------------------------------------- 1 | .env-card { 2 | box-shadow: none; 3 | margin-bottom: 20px; 4 | border-radius: 10px; 5 | } 6 | 7 | mat-card-content { 8 | font-size: medium; 9 | padding-top: 5px; 10 | } 11 | 12 | .env-card-first-content { 13 | padding-top: 10px; 14 | } 15 | 16 | .env-card-last-content { 17 | padding-bottom: 10px; 18 | } 19 | 20 | .env-card-header { 21 | position: absolute; 22 | right: 0; 23 | } 24 | 25 | .env-card-footer { 26 | position: absolute; 27 | right: 0; 28 | bottom: 0; 29 | } -------------------------------------------------------------------------------- /web/page0/src/app/env-card/env-card.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input} from '@angular/core'; 2 | import {Environment, EnvStatus} from '../interface/env-interface'; 3 | import {HostService} from '../host.service'; 4 | import {MatSnackBar} from '@angular/material/snack-bar'; 5 | 6 | const tooltips = { 7 | [EnvStatus.starting]: 'Starting', 8 | [EnvStatus.running]: 'Running', 9 | [EnvStatus.stopping]: 'Stopping', 10 | [EnvStatus.error]: 'Error', 11 | }; 12 | 13 | const icons = { 14 | [EnvStatus.starting]: 'pending', 15 | [EnvStatus.running]: 'check_circle', 16 | [EnvStatus.stopping]: 'stop_circle', 17 | [EnvStatus.error]: 'error', 18 | }; 19 | 20 | @Component({ 21 | standalone: false, 22 | selector: 'app-env-card', 23 | templateUrl: './env-card.component.html', 24 | styleUrls: ['./env-card.component.scss'], 25 | }) 26 | export class EnvCardComponent { 27 | @Input() env!: Environment; 28 | 29 | constructor( 30 | private hostService: HostService, 31 | private snackBar: MatSnackBar 32 | ) {} 33 | 34 | ngOnInit() {} 35 | 36 | getCardSetting() { 37 | const status = this.env.status; 38 | return { 39 | tooltip: tooltips[status], 40 | icon: icons[status], 41 | backgroundColor: 'aliceblue', 42 | }; 43 | } 44 | 45 | isRunning() { 46 | return this.env.status === EnvStatus.running; 47 | } 48 | 49 | onClickGoto() { 50 | const {hostUrl, groupName} = this.env; 51 | // TODO: use safeurl 52 | window.open(`${hostUrl}/?groupId=${groupName}`); 53 | } 54 | 55 | onClickDelete() { 56 | this.hostService.deleteHost(this.env.hostUrl).subscribe({ 57 | next: () => { 58 | this.snackBar.dismiss(); 59 | }, 60 | error: error => { 61 | this.snackBar.open( 62 | `Failed to delete host ${this.env.hostUrl} (error: ${error.message})`, 63 | 'Dismiss' 64 | ); 65 | }, 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /web/page0/src/app/env-form.service.spec.ts: -------------------------------------------------------------------------------- 1 | import {TestBed} from '@angular/core/testing'; 2 | 3 | import {EnvFormService} from './env-form.service'; 4 | 5 | describe('EnvFormService', () => { 6 | let service: EnvFormService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(EnvFormService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /web/page0/src/app/env-list-view/env-list-view.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/page0/src/app/env-list-view/env-list-view.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/cloud-android-orchestration/af32efba1ab841804c5ae265076a36d5516dd5cd/web/page0/src/app/env-list-view/env-list-view.component.scss -------------------------------------------------------------------------------- /web/page0/src/app/env-list-view/env-list-view.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: false, 5 | selector: 'app-env-list-view', 6 | templateUrl: './env-list-view.component.html', 7 | styleUrls: ['./env-list-view.component.scss'], 8 | }) 9 | export class EnvListViewComponent { 10 | constructor() {} 11 | 12 | ngOnInit() {} 13 | } 14 | -------------------------------------------------------------------------------- /web/page0/src/app/env.service.spec.ts: -------------------------------------------------------------------------------- 1 | import {TestBed} from '@angular/core/testing'; 2 | 3 | import {EnvService} from './env.service'; 4 | 5 | describe('EnvService', () => { 6 | let service: EnvService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(EnvService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /web/page0/src/app/fetch.service.spec.ts: -------------------------------------------------------------------------------- 1 | import {TestBed} from '@angular/core/testing'; 2 | 3 | import {FetchService} from './fetch.service'; 4 | 5 | describe('FetchService', () => { 6 | let service: FetchService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(FetchService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /web/page0/src/app/host.service.spec.ts: -------------------------------------------------------------------------------- 1 | import {TestBed} from '@angular/core/testing'; 2 | 3 | import {HostService} from './host.service'; 4 | 5 | describe('HostService', () => { 6 | let service: HostService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(HostService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /web/page0/src/app/host.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {ActionType} from 'src/app/store/actions'; 3 | import {Store} from 'src/app/store/store'; 4 | import {ApiService} from './api.service'; 5 | import { 6 | HostInstance, 7 | Operation, 8 | } from 'src/app/interface/cloud-orchestrator.dto'; 9 | import {Runtime} from 'src/app/interface/runtime-interface'; 10 | import {Observable, throwError} from 'rxjs'; 11 | import {catchError, map, tap} from 'rxjs/operators'; 12 | import {OperationService} from './operation.service'; 13 | import {HostStatus} from './interface/host-interface'; 14 | import {Result, ResultType} from './interface/result-interface'; 15 | 16 | @Injectable({ 17 | providedIn: 'root', 18 | }) 19 | export class HostService { 20 | createHost( 21 | hostInstance: HostInstance, 22 | runtime: Runtime, 23 | zone: string 24 | ): Observable> { 25 | const request = this.apiService.createHost(runtime.url, zone, { 26 | host_instance: hostInstance, 27 | }); 28 | 29 | const waitUrlSynthesizer = (op: Operation) => 30 | `${runtime.url}/v1/zones/${zone}/operations/${op.name}`; 31 | 32 | return this.waitService 33 | .wait(request, waitUrlSynthesizer) 34 | .pipe( 35 | map(result => { 36 | switch (result.type) { 37 | case ResultType.waitStarted: 38 | this.store.dispatch({ 39 | type: ActionType.HostCreateStart, 40 | wait: { 41 | waitUrl: result.waitUrl, 42 | metadata: { 43 | type: 'host-create', 44 | zone, 45 | runtimeAlias: runtime.alias, 46 | }, 47 | }, 48 | }); 49 | break; 50 | case ResultType.done: 51 | const hostName = result.data.name!; 52 | this.store.dispatch({ 53 | type: ActionType.HostCreateComplete, 54 | waitUrl: result.waitUrl, 55 | host: { 56 | name: hostName, 57 | zone, 58 | url: `${runtime.url}/v1/zones/${zone}/hosts/${hostName}`, 59 | runtime: runtime.alias, 60 | status: HostStatus.running, 61 | }, 62 | }); 63 | 64 | return { 65 | type: ResultType.done as ResultType.done, 66 | waitUrl: result.waitUrl, 67 | data: true, 68 | }; 69 | default: 70 | break; 71 | } 72 | 73 | return result; 74 | }), 75 | catchError(error => { 76 | this.store.dispatch({ 77 | type: ActionType.HostCreateError, 78 | }); 79 | 80 | return throwError(() => error); 81 | }) 82 | ); 83 | } 84 | 85 | deleteHost(hostUrl: string) { 86 | this.store.dispatch({ 87 | type: ActionType.HostDeleteStart, 88 | wait: { 89 | waitUrl: hostUrl, 90 | metadata: { 91 | type: 'host-delete', 92 | hostUrl, 93 | }, 94 | }, 95 | }); 96 | 97 | return this.apiService.deleteHost(hostUrl).pipe( 98 | tap(() => { 99 | this.store.dispatch({ 100 | type: ActionType.HostDeleteComplete, 101 | waitUrl: hostUrl, 102 | }); 103 | }), 104 | catchError(error => { 105 | this.store.dispatch({ 106 | type: ActionType.HostDeleteError, 107 | waitUrl: hostUrl, 108 | }); 109 | 110 | return throwError(() => error); 111 | }) 112 | ); 113 | } 114 | 115 | constructor( 116 | private apiService: ApiService, 117 | private store: Store, 118 | private waitService: OperationService 119 | ) {} 120 | } 121 | -------------------------------------------------------------------------------- /web/page0/src/app/interface/cloud-orchestrator.dto.ts: -------------------------------------------------------------------------------- 1 | // Should be aligned with api/v1/instancemanager.go 2 | 3 | export declare interface CreateHostRequest { 4 | host_instance: HostInstance; 5 | } 6 | 7 | export declare interface HostInstance { 8 | name?: string; 9 | boot_disk_size_gb?: number; 10 | gcp?: GCPInstance; 11 | } 12 | export declare interface GCPInstance { 13 | machine_type: string; 14 | min_cpu_platform: string; 15 | } 16 | 17 | export declare interface Operation { 18 | name: string; 19 | metadata?: any; 20 | done: boolean; 21 | } 22 | 23 | export declare interface OperationResult { 24 | error?: object; 25 | response?: string; 26 | } 27 | 28 | export declare interface ListHostsResponse { 29 | items?: HostInstance[]; 30 | nextPageToken?: string; 31 | } 32 | 33 | export declare interface RuntimeConfig { 34 | instance_manager_type: 'GCP' | 'local'; 35 | // TODO: Add other information e.g. chipset, machine_type 36 | } 37 | 38 | export declare interface Zone { 39 | name: string; 40 | } 41 | 42 | export declare interface ListZonesResponse { 43 | items?: Zone[]; 44 | } 45 | -------------------------------------------------------------------------------- /web/page0/src/app/interface/component-interface.ts: -------------------------------------------------------------------------------- 1 | import {Environment} from './env-interface'; 2 | import {HostStatus} from './host-interface'; 3 | import {RuntimeStatus} from './runtime-interface'; 4 | 5 | export interface HostItem { 6 | name: string; 7 | zone?: string; 8 | url?: string; 9 | runtime: string; 10 | status: HostStatus; 11 | envs: Environment[]; 12 | } 13 | 14 | export interface RuntimeCard { 15 | alias: string; 16 | type?: 'local' | 'on-premise' | 'cloud'; 17 | url: string; 18 | hosts: HostItem[]; 19 | status: RuntimeStatus; 20 | } 21 | 22 | export type EnvCard = Environment; 23 | -------------------------------------------------------------------------------- /web/page0/src/app/interface/device-interface.ts: -------------------------------------------------------------------------------- 1 | export interface DeviceSetting { 2 | deviceId: string; 3 | target: string; 4 | branch_or_buildId: string; 5 | } 6 | 7 | export interface GroupForm { 8 | groupName: string; 9 | devices: DeviceSetting[]; 10 | } 11 | -------------------------------------------------------------------------------- /web/page0/src/app/interface/env-interface.ts: -------------------------------------------------------------------------------- 1 | import {DeviceSetting} from './device-interface'; 2 | 3 | export enum EnvStatus { 4 | starting = 'starting', 5 | running = 'running', 6 | stopping = 'stopping', 7 | error = 'error', 8 | } 9 | 10 | export interface Environment { 11 | runtimeAlias: string; 12 | hostUrl: string; 13 | groupName: string; 14 | devices: DeviceSetting[]; 15 | status: EnvStatus; 16 | } 17 | 18 | export interface CommonEnvConfig { 19 | group_name: string; 20 | } 21 | 22 | export interface DiskConfig { 23 | default_build: string; 24 | } 25 | 26 | export interface InstanceConfig { 27 | name: string; 28 | disk: DiskConfig; 29 | } 30 | 31 | export interface EnvConfig { 32 | common: CommonEnvConfig; 33 | instances: InstanceConfig[]; 34 | } 35 | -------------------------------------------------------------------------------- /web/page0/src/app/interface/host-interface.ts: -------------------------------------------------------------------------------- 1 | export enum HostStatus { 2 | starting = 'starting', 3 | running = 'running', 4 | stopping = 'stopping', 5 | error = 'error', 6 | loading = 'loading', 7 | } 8 | 9 | export interface Host { 10 | name: string; 11 | zone?: string; 12 | url?: string; 13 | runtime: string; 14 | status: HostStatus; 15 | } 16 | -------------------------------------------------------------------------------- /web/page0/src/app/interface/host-orchestrator.dto.ts: -------------------------------------------------------------------------------- 1 | export declare interface AndroidCIBuild { 2 | branch: string; 3 | build_id: string; 4 | target: string; 5 | } 6 | 7 | export declare interface AndroidCIBuildSource { 8 | main_build: AndroidCIBuild; 9 | // kernel_build?: AndroidCIBuild; 10 | // bootloader_build?: AndroidCIBuild; 11 | // system_image_build?: AndroidCIBuild; 12 | // credentials?: string; 13 | } 14 | 15 | export declare interface CVD { 16 | name: string; 17 | build_source: BuildSource; 18 | status: string; 19 | displays: string[]; 20 | group_name?: string; // TODO: Not in current host orchestrator 21 | } 22 | 23 | export declare interface BuildSource { 24 | android_ci_build_source: AndroidCIBuildSource; 25 | // TODO: user build 26 | } 27 | 28 | export declare interface ListCVDsResponse { 29 | cvds: CVD[]; 30 | } 31 | 32 | export declare interface Group { 33 | name: string; 34 | cvds: CVD[]; 35 | } 36 | 37 | export declare interface CreateCVDRequest { 38 | env_config: object; 39 | } 40 | -------------------------------------------------------------------------------- /web/page0/src/app/interface/result-interface.ts: -------------------------------------------------------------------------------- 1 | export enum ResultType { 2 | waitStarted = 'wait-started', 3 | done = 'done', 4 | } 5 | 6 | export interface WaitStartedResult { 7 | type: ResultType.waitStarted; 8 | waitUrl: string; 9 | } 10 | 11 | export interface DoneResult { 12 | type: ResultType.done; 13 | waitUrl: string; 14 | data: T; 15 | } 16 | 17 | export type Result = DoneResult | WaitStartedResult; 18 | -------------------------------------------------------------------------------- /web/page0/src/app/interface/runtime-interface.ts: -------------------------------------------------------------------------------- 1 | export enum RuntimeStatus { 2 | valid = 'valid', 3 | error = 'error', 4 | loading = 'loading', 5 | } 6 | 7 | export enum RuntimeType { 8 | local = 'local', 9 | onPremise = 'on-premise', 10 | cloud = 'cloud', 11 | } 12 | 13 | export interface RuntimeInfo { 14 | type: RuntimeType; 15 | } 16 | 17 | export interface Runtime { 18 | alias: string; 19 | type?: RuntimeType; 20 | url: string; 21 | zones?: string[]; 22 | status: RuntimeStatus; 23 | } 24 | 25 | export enum RuntimeViewStatus { 26 | initializing = 'initializing', 27 | refreshing = 'refreshing', 28 | registering = 'registering', 29 | register_error = 'register_error', 30 | done = 'done', 31 | } 32 | -------------------------------------------------------------------------------- /web/page0/src/app/interface/utils.ts: -------------------------------------------------------------------------------- 1 | import {DeviceSetting} from 'src/app/interface/device-interface'; 2 | import {CVD, Group} from 'src/app/interface/host-orchestrator.dto'; 3 | import {RuntimeConfig} from './cloud-orchestrator.dto'; 4 | import {EnvConfig, Environment, EnvStatus} from './env-interface'; 5 | import {RuntimeInfo, RuntimeType} from './runtime-interface'; 6 | 7 | export function cvdToDevice(cvd: CVD): DeviceSetting { 8 | const {name, build_source} = cvd; 9 | 10 | const android_ci_build_source = build_source.android_ci_build_source; 11 | const main_build = 12 | android_ci_build_source ? android_ci_build_source.main_build : null; 13 | const branch = main_build ? main_build.branch : null; 14 | const build_id = main_build ? main_build.build_id : null; 15 | const target = main_build ? main_build.target : null; 16 | 17 | return { 18 | deviceId: name || 'unknown', 19 | branch_or_buildId: build_id || branch || 'unknown', 20 | target: target || 'unknown', 21 | }; 22 | }; 23 | 24 | export function groupToEnv( 25 | runtimeAlias: string, 26 | hostUrl: string, 27 | group: Group 28 | ): Environment { 29 | return { 30 | runtimeAlias, 31 | hostUrl, 32 | groupName: group.name || 'unknown', 33 | devices: group.cvds.map(cvdToDevice), 34 | status: EnvStatus.running, 35 | }; 36 | }; 37 | 38 | export function configToInfo(config: RuntimeConfig): RuntimeInfo { 39 | const {instance_manager_type} = config; 40 | if (instance_manager_type === 'GCP') { 41 | return { 42 | type: RuntimeType.cloud, 43 | }; 44 | } 45 | 46 | return { 47 | type: RuntimeType.local, 48 | }; 49 | }; 50 | 51 | export function envConfigToEnv(config: EnvConfig): { 52 | groupName: string; 53 | devices: DeviceSetting[]; 54 | } { 55 | return { 56 | groupName: config?.common?.group_name || '', 57 | devices: 58 | config?.instances?.map(instance => { 59 | const [_, branch_or_buildId, target] = 60 | instance.disk.default_build.split('/'); 61 | 62 | return { 63 | deviceId: instance?.name || '', 64 | branch_or_buildId, 65 | target, 66 | }; 67 | }) || [], 68 | }; 69 | } 70 | 71 | export function deviceToInstanceConfig(device: DeviceSetting) { 72 | return { 73 | name: device.deviceId, 74 | disk: { 75 | default_build: `@ab/${device.branch_or_buildId}/${device.target}`, 76 | }, 77 | }; 78 | } 79 | 80 | export function parseEnvConfig(canonicalConfig: string | null | undefined) { 81 | if (!canonicalConfig) { 82 | throw new Error('Cannot parse empty string'); 83 | } 84 | 85 | const config = JSON.parse(canonicalConfig) as EnvConfig; 86 | return envConfigToEnv(config); 87 | } 88 | -------------------------------------------------------------------------------- /web/page0/src/app/interface/wait-interface.ts: -------------------------------------------------------------------------------- 1 | import {DeviceSetting} from './device-interface'; 2 | 3 | export type Wait = 4 | | HostCreateWait 5 | | HostDeleteWait 6 | | EnvCreateWait 7 | | EnvAutoHostCreateWait; 8 | 9 | export interface HostCreateWait { 10 | waitUrl: string; 11 | metadata: { 12 | type: 'host-create'; 13 | zone: string; 14 | runtimeAlias: string; 15 | }; 16 | } 17 | 18 | export interface HostDeleteWait { 19 | waitUrl: string; 20 | metadata: { 21 | type: 'host-delete'; 22 | hostUrl: string; 23 | }; 24 | } 25 | 26 | export interface EnvCreateWait { 27 | waitUrl: string; 28 | metadata: { 29 | type: 'env-create'; 30 | hostUrl: string; 31 | groupName: string; 32 | runtimeAlias: string; 33 | devices: DeviceSetting[]; 34 | }; 35 | } 36 | 37 | export interface EnvAutoHostCreateWait { 38 | waitUrl: string; 39 | metadata: { 40 | type: 'env-auto-host-create'; 41 | zone: string; 42 | groupName: string; 43 | runtimeAlias: string; 44 | devices: DeviceSetting[]; 45 | }; 46 | } 47 | 48 | export function isHostCreateWait(wait: Wait): wait is HostCreateWait { 49 | return (wait as HostCreateWait).metadata.type === 'host-create'; 50 | } 51 | 52 | export function isHostDeleteWait(wait: Wait): wait is HostDeleteWait { 53 | return (wait as HostDeleteWait).metadata.type === 'host-delete'; 54 | } 55 | 56 | export function isEnvCreateWait(wait: Wait): wait is EnvCreateWait { 57 | return (wait as EnvCreateWait).metadata.type === 'env-create'; 58 | } 59 | 60 | export function isEnvAutoHostCreateWait( 61 | wait: Wait 62 | ): wait is EnvAutoHostCreateWait { 63 | return ( 64 | (wait as EnvAutoHostCreateWait).metadata.type === 'env-auto-host-create' 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /web/page0/src/app/json.utils.ts: -------------------------------------------------------------------------------- 1 | function setValue( 2 | root: object, 3 | selectors: string[], 4 | value: any, 5 | options: {ifexists: 'override' | 'skip'} = {ifexists: 'override'} 6 | ) { 7 | let traversal: any = root; 8 | for (const [i, selector] of selectors.entries()) { 9 | if (i !== selectors.length - 1) { 10 | if (!traversal[selector]) { 11 | traversal[selector] = {}; 12 | } 13 | 14 | traversal = traversal[selector]; 15 | continue; 16 | } 17 | 18 | if (!traversal[selector] || options.ifexists === 'override') { 19 | traversal[selector] = value; 20 | } 21 | } 22 | } 23 | 24 | function parse(s: string | undefined | null): any { 25 | if (!s) { 26 | return {}; 27 | } 28 | 29 | try { 30 | return JSON.parse(s) as object; 31 | } catch { 32 | return {}; 33 | } 34 | } 35 | 36 | function stringify(o: object) { 37 | return JSON.stringify(o, undefined, 2); 38 | } 39 | 40 | export default {setValue, parse, stringify}; 41 | -------------------------------------------------------------------------------- /web/page0/src/app/list-runtime-view/list-runtime-view.component.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    Runtimes

    5 |
    6 | 7 |
    8 | 16 | 19 |
    20 |
    21 | 22 | 23 | 28 | 29 |
    30 |
    31 |
  • 32 | 33 |
  • 34 |
    35 | 36 | 37 |

    No runtime registered

    38 |
    39 |
    40 |
    41 | -------------------------------------------------------------------------------- /web/page0/src/app/list-runtime-view/list-runtime-view.component.scss: -------------------------------------------------------------------------------- 1 | .runtime-view { 2 | background-color: white; 3 | padding: 20px; 4 | } 5 | 6 | #runtime-view-header-title { 7 | margin-right: 48px; 8 | } 9 | 10 | .runtime-view-header { 11 | display: flex; 12 | flex-direction: row; 13 | } 14 | 15 | 16 | li { 17 | list-style: none; 18 | } 19 | 20 | .runtime-view-header-actions { 21 | display: flex; 22 | flex-direction: row; 23 | justify-content: space-between; 24 | } 25 | 26 | mat-divider { 27 | padding-bottom: 20px; 28 | } 29 | 30 | .view-action { 31 | padding: 0px 12px; 32 | margin-right: 8px; 33 | } -------------------------------------------------------------------------------- /web/page0/src/app/list-runtime-view/list-runtime-view.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, inject} from '@angular/core'; 2 | import {Store} from 'src/app/store/store'; 3 | import {RefreshService} from '../refresh.service'; 4 | import {RuntimeViewStatus} from 'src/app/interface/runtime-interface'; 5 | import {RuntimeService} from '../runtime.service'; 6 | import {runtimesLoadStatusSelector} from '../store/selectors'; 7 | 8 | @Component({ 9 | standalone: false, 10 | selector: 'app-list-runtime-view', 11 | templateUrl: './list-runtime-view.component.html', 12 | styleUrls: ['./list-runtime-view.component.scss'], 13 | }) 14 | export class ListRuntimeViewComponent { 15 | private runtimeService = inject(RuntimeService); 16 | private refreshService = inject(RefreshService); 17 | private store = inject(Store); 18 | 19 | runtimes$ = this.runtimeService.getRuntimes(); 20 | status$ = this.store.select(runtimesLoadStatusSelector); 21 | 22 | onClickRefresh() { 23 | this.refreshService.refresh(); 24 | } 25 | 26 | showProgressBar(status: string | null) { 27 | return ( 28 | status === RuntimeViewStatus.initializing || 29 | status === RuntimeViewStatus.refreshing 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /web/page0/src/app/operation.service.spec.ts: -------------------------------------------------------------------------------- 1 | import {TestBed} from '@angular/core/testing'; 2 | 3 | import {OperationService} from './operation.service'; 4 | 5 | describe('OperationService', () => { 6 | let service: OperationService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(OperationService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /web/page0/src/app/operation.service.ts: -------------------------------------------------------------------------------- 1 | import {HttpErrorResponse} from '@angular/common/http'; 2 | import {Injectable} from '@angular/core'; 3 | import {merge, Observable, timer} from 'rxjs'; 4 | import {map, mergeAll, retry, shareReplay, switchMap} from 'rxjs/operators'; 5 | import {ApiService} from './api.service'; 6 | import {Operation} from './interface/cloud-orchestrator.dto'; 7 | import { 8 | DoneResult, 9 | Result, 10 | ResultType, 11 | WaitStartedResult, 12 | } from './interface/result-interface'; 13 | 14 | @Injectable({ 15 | providedIn: 'root', 16 | }) 17 | export class OperationService { 18 | constructor(private apiService: ApiService) {} 19 | 20 | private longPolling(waitUrl: string): Observable { 21 | const retryConfig = { 22 | count: 1000, 23 | delay: (err: HttpErrorResponse, retryCount: number) => { 24 | if (retryCount % 10 === 0) { 25 | console.warn( 26 | `Wait toward ${waitUrl} has failed for ${retryCount} times` 27 | ); 28 | } 29 | 30 | if (err.status === 503) { 31 | return timer(0); 32 | } 33 | 34 | throw new Error(`Request ${err.url} failed to be done`); 35 | }, 36 | }; 37 | 38 | return this.apiService.wait(waitUrl).pipe(retry(retryConfig)); 39 | } 40 | 41 | wait( 42 | request: Observable, 43 | waitUrlSynthesizer: (op: Operation) => string 44 | ): Observable> { 45 | const requestReplay = request.pipe(shareReplay(1)); 46 | 47 | const requestResult: Observable = requestReplay.pipe( 48 | map(operation => ({ 49 | type: ResultType.waitStarted as ResultType.waitStarted, 50 | waitUrl: waitUrlSynthesizer(operation), 51 | })) 52 | ); 53 | 54 | const waitResult: Observable> = requestReplay.pipe( 55 | switchMap(operation => { 56 | const waitUrl = waitUrlSynthesizer(operation); 57 | return this.longPolling(waitUrl).pipe( 58 | map(data => ({ 59 | type: ResultType.done as ResultType.done, 60 | data, 61 | waitUrl, 62 | })) 63 | ); 64 | }) 65 | ); 66 | 67 | return merge([requestResult, waitResult]).pipe(mergeAll()); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /web/page0/src/app/refresh.service.spec.ts: -------------------------------------------------------------------------------- 1 | import {TestBed} from '@angular/core/testing'; 2 | 3 | import {RefreshService} from './refresh.service'; 4 | 5 | describe('RefreshService', () => { 6 | let service: RefreshService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(RefreshService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /web/page0/src/app/refresh.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {ActionType} from 'src/app/store/actions'; 3 | import {Store} from 'src/app/store/store'; 4 | import {DEFAULT_RUNTIME_SETTINGS} from './settings'; 5 | import {forkJoin, Observable, Subscription} from 'rxjs'; 6 | import {defaultIfEmpty, map, switchMap, tap} from 'rxjs/operators'; 7 | import {Runtime} from 'src/app/interface/runtime-interface'; 8 | import {FetchService} from './fetch.service'; 9 | 10 | @Injectable({ 11 | providedIn: 'root', 12 | }) 13 | export class RefreshService { 14 | private prevSubscription: Subscription | undefined = undefined; 15 | 16 | private getStoredRuntimes(): Runtime[] { 17 | const runtimes = window.localStorage.getItem('runtimes'); 18 | // TODO: handle type error 19 | if (runtimes) { 20 | return JSON.parse(runtimes) as Runtime[]; 21 | } 22 | 23 | return []; 24 | } 25 | 26 | private getInitRuntimeSettings() { 27 | const storedRuntimes = this.getStoredRuntimes(); 28 | if (storedRuntimes.length === 0) { 29 | return DEFAULT_RUNTIME_SETTINGS; 30 | } 31 | 32 | return storedRuntimes.map(runtime => ({ 33 | alias: runtime.alias, 34 | url: runtime.url, 35 | })); 36 | } 37 | 38 | refreshRuntime(url: string, alias: string): Observable { 39 | return this.fetchService.fetchRuntime(url, alias).pipe( 40 | tap((runtime: Runtime) => { 41 | this.store.dispatch({ 42 | type: ActionType.RuntimeLoad, 43 | runtime, 44 | }); 45 | }), 46 | switchMap(runtime => this.fetchService.loadHosts(runtime)), 47 | map(() => { 48 | return; 49 | }) 50 | ); 51 | } 52 | 53 | refresh() { 54 | const settings = this.getInitRuntimeSettings(); 55 | 56 | if (this.prevSubscription) { 57 | this.prevSubscription.unsubscribe(); 58 | } 59 | 60 | this.store.dispatch({ 61 | type: ActionType.RefreshStart, 62 | }); 63 | 64 | const subscription = forkJoin( 65 | settings.map(({url, alias}) => this.refreshRuntime(url, alias)) 66 | ) 67 | .pipe(defaultIfEmpty([])) 68 | .subscribe({ 69 | complete: () => { 70 | this.store.dispatch({type: ActionType.RefreshComplete}); 71 | }, 72 | }); 73 | 74 | this.prevSubscription = subscription; 75 | } 76 | 77 | constructor( 78 | private store: Store, 79 | private fetchService: FetchService 80 | ) {} 81 | } 82 | -------------------------------------------------------------------------------- /web/page0/src/app/register-runtime-view/register-runtime-view.component.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |
    5 |

    New Runtime

    6 |
    7 |
    8 | 9 | 10 | 11 |
    12 | 13 | Unique Alias 14 | 15 | 16 |
    17 | 18 |
    19 | 20 | URL 21 | 22 | 23 |
    24 | 25 |
    26 | 27 |
    28 | 29 | 30 | 33 | 34 | 37 |
    38 |
    39 | -------------------------------------------------------------------------------- /web/page0/src/app/register-runtime-view/register-runtime-view.component.scss: -------------------------------------------------------------------------------- 1 | .register-runtime-view { 2 | background-color: white; 3 | padding: 20px; 4 | padding-bottom: 255px; 5 | // height: 100%; 6 | min-height: 680px; 7 | 8 | } 9 | 10 | .full-width-field { 11 | width: 100%; 12 | } 13 | 14 | 15 | .flex-field { 16 | flex: 1; 17 | margin-right: 10px; 18 | } 19 | 20 | .flex-field:last-child { 21 | flex: 1; 22 | margin-right: 0; 23 | } 24 | 25 | .flex-container { 26 | display: flex; 27 | flex-direction: row; 28 | justify-content: space-between; 29 | } 30 | 31 | mat-divider { 32 | margin-bottom: 10px; 33 | } 34 | 35 | .footbar { 36 | padding: 30px; 37 | position: fixed; 38 | bottom: 0; 39 | left: 0; 40 | height: 100px; 41 | background-color: whitesmoke; 42 | border-top-right-radius: 20px; 43 | border-top-left-radius: 20px; 44 | width: 100%; 45 | z-index: 1; 46 | } 47 | 48 | .footbar-button { 49 | font-size: large; 50 | margin-right: 20px; 51 | } -------------------------------------------------------------------------------- /web/page0/src/app/register-runtime-view/register-runtime-view.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, inject} from '@angular/core'; 2 | import {FormBuilder, Validators} from '@angular/forms'; 3 | import { 4 | ActivatedRoute, 5 | Event, 6 | NavigationEnd, 7 | Params, 8 | Router, 9 | } from '@angular/router'; 10 | import {RuntimeService} from '../runtime.service'; 11 | import {MatSnackBar} from '@angular/material/snack-bar'; 12 | import {RuntimeViewStatus} from 'src/app/interface/runtime-interface'; 13 | import {of, Subject} from 'rxjs'; 14 | import { 15 | catchError, 16 | filter, 17 | map, 18 | mergeMap, 19 | shareReplay, 20 | switchMap, 21 | takeUntil, 22 | withLatestFrom, 23 | } from 'rxjs/operators'; 24 | import {handleUrl} from '../utils'; 25 | import {Store} from 'src/app/store/store'; 26 | import {PLACEHOLDER_RUNTIME_SETTING} from '../settings'; 27 | import {runtimesLoadStatusSelector} from 'src/app/store/selectors'; 28 | import {FetchService} from '../fetch.service'; 29 | 30 | @Component({ 31 | standalone: false, 32 | selector: 'app-register-runtime-view', 33 | templateUrl: './register-runtime-view.component.html', 34 | styleUrls: ['./register-runtime-view.component.scss'], 35 | }) 36 | export class RegisterRuntimeViewComponent { 37 | private runtimeService = inject(RuntimeService); 38 | private formBuilder = inject(FormBuilder); 39 | private router = inject(Router); 40 | private snackBar = inject(MatSnackBar); 41 | private activatedRoute = inject(ActivatedRoute); 42 | private store = inject(Store); 43 | private fetchService = inject(FetchService); 44 | queryParams$ = this.router.events.pipe(filter((event: Event): event is NavigationEnd => event instanceof NavigationEnd), mergeMap(() => this.activatedRoute.queryParams), shareReplay(1)); 45 | private ngUnsubscribe = new Subject(); 46 | previousUrl$ = this.queryParams$.pipe(map((params: Params) => (params['previousUrl'] ?? 'list-runtime') as string)); 47 | runtimes$ = this.runtimeService.getRuntimes(); 48 | status$ = this.store.select(runtimesLoadStatusSelector); 49 | 50 | constructor() { 51 | this.runtimeForm = this.formBuilder.group({ 52 | url: [PLACEHOLDER_RUNTIME_SETTING.url, Validators.required], 53 | alias: [PLACEHOLDER_RUNTIME_SETTING.alias, Validators.required], 54 | }); 55 | this.queryParams$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(); 56 | } 57 | 58 | runtimeForm; 59 | 60 | showProgressBar(status: RuntimeViewStatus | null) { 61 | return status === RuntimeViewStatus.registering; 62 | } 63 | 64 | ngOnInit() { 65 | this.runtimes$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(); 66 | } 67 | 68 | onSubmit() { 69 | const url = handleUrl(this.runtimeForm.value.url); 70 | const alias = this.runtimeForm.value.alias; 71 | 72 | if (!url || !alias) { 73 | return; 74 | } 75 | 76 | this.runtimeService 77 | .registerRuntime(alias, url) 78 | .pipe( 79 | withLatestFrom(this.previousUrl$), 80 | map(([runtime, previousUrl]) => { 81 | this.router.navigate([previousUrl]); 82 | this.snackBar.dismiss(); 83 | return runtime; 84 | }), 85 | catchError(error => { 86 | this.snackBar.open(error.message, 'Dismiss'); 87 | return of(undefined); 88 | }), 89 | switchMap(runtime => { 90 | if (!runtime) { 91 | return of(); 92 | } 93 | 94 | return this.fetchService.loadHosts(runtime); 95 | }) 96 | ) 97 | .subscribe(); 98 | } 99 | 100 | onCancel() { 101 | this.previousUrl$ 102 | .pipe(takeUntil(this.ngUnsubscribe)) 103 | .subscribe(previousUrl => { 104 | this.router.navigate([previousUrl]); 105 | }); 106 | } 107 | 108 | ngOnDestroy() { 109 | this.ngUnsubscribe.next(); 110 | this.ngUnsubscribe.complete(); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /web/page0/src/app/runtime-card/runtime-card.component.html: -------------------------------------------------------------------------------- 1 | 2 | Alias: {{ runtimeCard.alias }} 3 | 4 | Type: {{ runtimeCard.type }} 5 | 6 | URL: {{ runtimeCard.url }} 7 | 8 |
    9 | 12 | 13 | 16 |
    17 | 18 | 23 | 24 | 25 | Zones & Hosts: 26 |
      27 |
    • 28 |
      29 |
      {{ host.name }} ({{ host.zone }}) ({{ host.status }})
      30 | 31 | 34 |
      35 |
        36 |
      • {{ env.groupName }}
      • 37 |
      38 |
    • 39 |
    40 |
    41 |
    42 | -------------------------------------------------------------------------------- /web/page0/src/app/runtime-card/runtime-card.component.scss: -------------------------------------------------------------------------------- 1 | .runtime-card { 2 | // box-shadow: none; 3 | margin-bottom: 20px; 4 | border-radius: 10px; 5 | max-width: 480px; 6 | min-height: 120px; 7 | } 8 | 9 | 10 | .host-header { 11 | display: flex; 12 | align-items: center; 13 | 14 | } 15 | 16 | .runtime-card-header { 17 | position: absolute; 18 | right: 0; 19 | } 20 | 21 | .host-delete-button {} 22 | 23 | .error-status {} 24 | 25 | 26 | 27 | .runtime-card-footer { 28 | position: absolute; 29 | right: 0; 30 | bottom: 0; 31 | } 32 | 33 | 34 | mat-card-content:first-child { 35 | margin-top: 20px; 36 | } 37 | 38 | mat-card-content:last-child { 39 | margin-bottom: 20px; 40 | } -------------------------------------------------------------------------------- /web/page0/src/app/runtime-card/runtime-card.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input} from '@angular/core'; 2 | import {MatSnackBar} from '@angular/material/snack-bar'; 3 | import {Router} from '@angular/router'; 4 | import {runtimeCardSelectorFactory} from 'src/app/store/selectors'; 5 | import {Store} from 'src/app/store/store'; 6 | import {Host, HostStatus} from 'src/app/interface/host-interface'; 7 | import {HostService} from '../host.service'; 8 | import {RuntimeService} from '../runtime.service'; 9 | 10 | @Component({ 11 | standalone: false, 12 | selector: 'app-runtime-card', 13 | templateUrl: './runtime-card.component.html', 14 | styleUrls: ['./runtime-card.component.scss'], 15 | }) 16 | export class RuntimeCardComponent { 17 | @Input() runtimeAlias = ''; 18 | 19 | getRuntimeCard = (alias: string) => 20 | this.store.select(runtimeCardSelectorFactory(alias)); 21 | 22 | constructor( 23 | private router: Router, 24 | private runtimeService: RuntimeService, 25 | private hostService: HostService, 26 | private snackBar: MatSnackBar, 27 | private store: Store 28 | ) {} 29 | 30 | onClickAddHost() { 31 | this.router.navigate(['/create-host'], { 32 | queryParams: {runtime: this.runtimeAlias}, 33 | }); 34 | } 35 | 36 | onClickUnregister(alias: string | undefined) { 37 | if (!alias) { 38 | return; 39 | } 40 | 41 | this.runtimeService.unregisterRuntime(alias); 42 | } 43 | 44 | onClickDeleteHost(host: Host) { 45 | if (host.status !== HostStatus.running || !host.url) { 46 | this.snackBar.open('Cannot delete non-running host', 'Dismiss'); 47 | return; 48 | } 49 | 50 | this.snackBar.open( 51 | `Start to delete host ${host.name} (url: ${host.url})`, 52 | 'Dismiss' 53 | ); 54 | 55 | this.hostService.deleteHost(host.url!).subscribe({ 56 | next: () => { 57 | this.snackBar.dismiss(); 58 | }, 59 | error: error => { 60 | this.snackBar.open( 61 | `Failed to delete host ${host.url} (error: ${error.message})`, 62 | 'Dismiss' 63 | ); 64 | }, 65 | }); 66 | } 67 | 68 | isRunning(host: Host) { 69 | return host.status === HostStatus.running; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /web/page0/src/app/runtime.service.spec.ts: -------------------------------------------------------------------------------- 1 | import {TestBed} from '@angular/core/testing'; 2 | 3 | import {RuntimeService} from './runtime.service'; 4 | 5 | describe('RuntimeService', () => { 6 | let service: RuntimeService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(RuntimeService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /web/page0/src/app/runtime.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, inject} from '@angular/core'; 2 | import {Observable, of} from 'rxjs'; 3 | import {map, tap, withLatestFrom, catchError, switchMap} from 'rxjs/operators'; 4 | import { 5 | runtimeListSelector, 6 | runtimesLoadStatusSelector, 7 | } from 'src/app/store/selectors'; 8 | import {ActionType} from 'src/app/store/actions'; 9 | import {Store} from 'src/app/store/store'; 10 | import {FetchService} from './fetch.service'; 11 | import {Runtime, RuntimeViewStatus} from 'src/app/interface/runtime-interface'; 12 | 13 | @Injectable({ 14 | providedIn: 'root', 15 | }) 16 | export class RuntimeService { 17 | private store = inject(Store); 18 | private fetchService = inject(FetchService); 19 | private status$ = this.store.select(runtimesLoadStatusSelector); 20 | private runtimes$: Observable = this.store 21 | .select(runtimeListSelector) 22 | .pipe(withLatestFrom(this.status$), map(([runtimes, status]) => { 23 | if (status === RuntimeViewStatus.done) { 24 | window.localStorage.setItem('runtimes', JSON.stringify(runtimes)); 25 | } 26 | return runtimes; 27 | })); 28 | 29 | getRuntimes() { 30 | return this.runtimes$; 31 | } 32 | 33 | registerRuntime(alias: string, url: string) { 34 | return of(null).pipe( 35 | withLatestFrom(this.runtimes$), 36 | tap(() => this.store.dispatch({type: ActionType.RuntimeRegisterStart})), 37 | map(([_, runtimes]) => { 38 | if (runtimes.some(runtime => runtime.alias === alias)) { 39 | throw Error(`Cannot have runtime of duplicated alias: ${alias}`); 40 | } 41 | }), 42 | switchMap(() => this.fetchService.fetchRuntime(url, alias)), 43 | tap(runtime => { 44 | if (runtime.status === 'error') { 45 | throw new Error(`Cannot register runtime ${alias} (url: ${url})`); 46 | } 47 | }), 48 | tap(runtime => 49 | this.store.dispatch({ 50 | type: ActionType.RuntimeRegisterComplete, 51 | runtime, 52 | }) 53 | ), 54 | catchError(error => { 55 | this.store.dispatch({ 56 | type: ActionType.RuntimeRegisterError, 57 | }); 58 | 59 | throw error; 60 | }) 61 | ); 62 | } 63 | 64 | unregisterRuntime(alias: string) { 65 | this.store.dispatch({ 66 | type: ActionType.RuntimeUnregister, 67 | alias, 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /web/page0/src/app/safe-url.pipe.ts: -------------------------------------------------------------------------------- 1 | import {Pipe, PipeTransform} from '@angular/core'; 2 | import {DomSanitizer} from '@angular/platform-browser'; 3 | 4 | @Pipe({ 5 | standalone: false, 6 | name: 'safeurl', 7 | }) 8 | export class SafeUrlPipe implements PipeTransform { 9 | constructor(private sanitizer: DomSanitizer) {} 10 | 11 | transform(url: string) { 12 | /* eslint-disable */ 13 | // DO NOT FORMAT THIS LINE 14 | return this.sanitizer.bypassSecurityTrustResourceUrl(url); 15 | /* eslint-enable */ 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /web/page0/src/app/settings.ts: -------------------------------------------------------------------------------- 1 | import {HostInstance} from './interface/cloud-orchestrator.dto'; 2 | 3 | export const DEFAULT_RUNTIME_SETTINGS = []; 4 | 5 | export const PLACEHOLDER_RUNTIME_SETTING = { 6 | alias: 'example-runtime-setting', 7 | url: 'https://example-runtime-setting.com/', 8 | }; 9 | 10 | export const DEFAULT_ENV_CONFIG: object = { 11 | // common: { 12 | // group_name: 'simulated_home', 13 | // }, 14 | instances: [ 15 | { 16 | name: 'my_phone', 17 | disk: { 18 | default_build: 19 | '@ab/aosp-main/aosp_cf_x86_64_phone-trunk_staging-userdebug', 20 | }, 21 | }, 22 | // { 23 | // name: 'my_watch', 24 | // disk: { 25 | // default_build: '@ab/git_main/cf_gwear_x86-trunk_staging-userdebug', 26 | // }, 27 | // }, 28 | ], 29 | }; 30 | 31 | export const DEFAULT_ZONE = 'us-east1-b'; 32 | 33 | export const DEFAULT_HOST_SETTING: HostInstance = { 34 | gcp: { 35 | machine_type: 'n1-standard-4', 36 | min_cpu_platform: 'Intel Skylake', 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /web/page0/src/app/store/state.ts: -------------------------------------------------------------------------------- 1 | import {Environment} from 'src/app/interface/env-interface'; 2 | import {Runtime, RuntimeViewStatus} from 'src/app/interface/runtime-interface'; 3 | import {Wait} from 'src/app/interface/wait-interface'; 4 | import {Host} from '../interface/host-interface'; 5 | 6 | export interface AppState { 7 | runtimes: Runtime[]; 8 | hosts: Host[]; 9 | envs: Environment[]; 10 | runtimesLoadStatus: RuntimeViewStatus; 11 | waits: {[key: string]: Wait}; 12 | } 13 | 14 | export const INITIAL_STATE: AppState = { 15 | runtimes: [], 16 | hosts: [], 17 | envs: [], 18 | runtimesLoadStatus: RuntimeViewStatus.initializing, 19 | waits: {}, 20 | }; 21 | -------------------------------------------------------------------------------- /web/page0/src/app/store/store.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {Observable, ReplaySubject} from 'rxjs'; 3 | import {map, scan, shareReplay, startWith} from 'rxjs/operators'; 4 | import {Action, InitAction} from './actions'; 5 | import {match} from './reducers'; 6 | import {AppState, INITIAL_STATE} from './state'; 7 | 8 | @Injectable({ 9 | providedIn: 'root', 10 | }) 11 | export class Store { 12 | constructor() {} 13 | 14 | action$ = new ReplaySubject(); 15 | 16 | // should be updated by all reducers 17 | private state$ = this.action$.pipe( 18 | startWith({type: 'init'} as InitAction), 19 | map(action => { 20 | return match(action); 21 | }), 22 | scan((prevState, handler) => handler(prevState), INITIAL_STATE), 23 | shareReplay(1) 24 | ); 25 | 26 | select(selector: (state: AppState) => T): Observable { 27 | return this.state$.pipe(map(state => selector(state))); 28 | } 29 | 30 | dispatch(action: Action) { 31 | this.action$.next(action); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /web/page0/src/app/utils.ts: -------------------------------------------------------------------------------- 1 | export function hasDuplicate(items: T[]): boolean { 2 | const s = new Set(items); 3 | return Array.from(s.values()).length < items.length; 4 | } 5 | 6 | export function handleUrl(url: string | null | undefined): string { 7 | if (!url) { 8 | return ''; 9 | } 10 | 11 | if (!url.endsWith('/')) { 12 | return url; 13 | } 14 | 15 | return url.slice(0, url.length - 1); 16 | } 17 | 18 | export function adjustArrayLength(arr: T[], length: number, placeholder: T) { 19 | const currentArrayLength = arr.length; 20 | 21 | for (let cnt = length; cnt < currentArrayLength; cnt++) { 22 | arr.pop(); 23 | } 24 | 25 | for (let cnt = currentArrayLength; cnt < length; cnt++) { 26 | arr.push(placeholder); 27 | } 28 | 29 | return arr; 30 | } 31 | 32 | export const AUTO_CREATE_HOST = 'auto_create_host'; 33 | -------------------------------------------------------------------------------- /web/page0/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/cloud-android-orchestration/af32efba1ab841804c5ae265076a36d5516dd5cd/web/page0/src/assets/.gitkeep -------------------------------------------------------------------------------- /web/page0/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/cloud-android-orchestration/af32efba1ab841804c5ae265076a36d5516dd5cd/web/page0/src/favicon.ico -------------------------------------------------------------------------------- /web/page0/src/http_interceptors/auth.interceptor.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import { 3 | HttpRequest, 4 | HttpHandler, 5 | HttpEvent, 6 | HttpInterceptor, 7 | HttpErrorResponse, 8 | } from '@angular/common/http'; 9 | import {Observable} from 'rxjs'; 10 | import {catchError} from 'rxjs/operators'; 11 | import {MatSnackBar} from '@angular/material/snack-bar'; 12 | 13 | const handleAuthError = (error: HttpErrorResponse, snackBar: MatSnackBar) => { 14 | snackBar.open( 15 | `Request failed: check your credentials (error message: ${error.message})`, 16 | 'Dismiss' 17 | ); 18 | }; 19 | 20 | @Injectable() 21 | export class AuthInterceptor implements HttpInterceptor { 22 | constructor(private snackBar: MatSnackBar) {} 23 | 24 | intercept( 25 | request: HttpRequest, 26 | next: HttpHandler 27 | ): Observable> { 28 | return next.handle(request).pipe( 29 | catchError((error: HttpErrorResponse) => { 30 | // If credentials got expired, OPTIONS request fails before the actual request 31 | // Thus, error status becomes 0 for CORS failure 32 | if (error.status === 0) { 33 | handleAuthError(error, this.snackBar); 34 | } 35 | 36 | throw error; 37 | }) 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /web/page0/src/http_interceptors/cors.interceptor.spec.ts: -------------------------------------------------------------------------------- 1 | import {TestBed} from '@angular/core/testing'; 2 | 3 | import {CorsInterceptor} from './cors.interceptor'; 4 | 5 | describe('CorsInterceptor', () => { 6 | beforeEach(() => 7 | TestBed.configureTestingModule({ 8 | providers: [CorsInterceptor], 9 | }) 10 | ); 11 | 12 | it('should be created', () => { 13 | const interceptor: CorsInterceptor = TestBed.inject(CorsInterceptor); 14 | expect(interceptor).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /web/page0/src/http_interceptors/cors.interceptor.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import { 3 | HttpRequest, 4 | HttpHandler, 5 | HttpEvent, 6 | HttpInterceptor, 7 | } from '@angular/common/http'; 8 | import {Observable} from 'rxjs'; 9 | 10 | @Injectable() 11 | export class CorsInterceptor implements HttpInterceptor { 12 | constructor() {} 13 | 14 | intercept( 15 | req: HttpRequest, 16 | next: HttpHandler 17 | ): Observable> { 18 | return next.handle( 19 | req.clone({ 20 | withCredentials: true, 21 | }) 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web/page0/src/http_interceptors/index.ts: -------------------------------------------------------------------------------- 1 | import {HTTP_INTERCEPTORS} from '@angular/common/http'; 2 | import {AuthInterceptor} from './auth.interceptor'; 3 | import {CorsInterceptor} from './cors.interceptor'; 4 | 5 | export const HTTP_INTERCEPTOR_PROVIDERS = [ 6 | {provide: HTTP_INTERCEPTORS, useClass: CorsInterceptor, multi: true}, 7 | {provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true}, 8 | ]; 9 | -------------------------------------------------------------------------------- /web/page0/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Page0 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /web/page0/src/main.ts: -------------------------------------------------------------------------------- 1 | import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; 2 | 3 | import {AppModule} from './app/app.module'; 4 | 5 | platformBrowserDynamic() 6 | .bootstrapModule(AppModule) 7 | .catch(err => console.error(err)); 8 | -------------------------------------------------------------------------------- /web/page0/src/mock/apis.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/cloud-android-orchestration/af32efba1ab841804c5ae265076a36d5516dd5cd/web/page0/src/mock/apis.ts -------------------------------------------------------------------------------- /web/page0/src/mock/envs.ts: -------------------------------------------------------------------------------- 1 | import {Environment} from 'src/app/interface/env-interface'; 2 | 3 | export const MOCK_ENV_LIST: Environment[] = []; 4 | -------------------------------------------------------------------------------- /web/page0/src/mock/runtimes.ts: -------------------------------------------------------------------------------- 1 | import {Runtime} from 'src/app/interface/runtime-interface'; 2 | 3 | export const MOCK_RUNTIME_LIST: Runtime[] = []; 4 | -------------------------------------------------------------------------------- /web/page0/src/requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | flask[async] 3 | absl-py 4 | -------------------------------------------------------------------------------- /web/page0/src/server.py: -------------------------------------------------------------------------------- 1 | from absl import app as absl_app 2 | from absl import flags 3 | 4 | import flask 5 | import requests 6 | 7 | import test_apis 8 | 9 | EXCLUDED_HEADERS = { 10 | "content-encoding", 11 | "content-length", 12 | "transfer-encoding", 13 | "connection", 14 | } 15 | 16 | _PORT = flags.DEFINE_integer( 17 | "port", 18 | default=8071, 19 | help="default port", 20 | ) 21 | 22 | _ANGULAR_URL = flags.DEFINE_string( 23 | "angular_url", 24 | default="http://localhost:4200/", 25 | help="default angular url", 26 | ) 27 | 28 | app = flask.Flask(__name__) 29 | 30 | app.register_blueprint(test_apis.apis) 31 | 32 | 33 | @app.route("/") 34 | def index(): 35 | response = requests.get(_ANGULAR_URL.value) 36 | 37 | headers = [ 38 | (key, value) 39 | for (key, value) in response.raw.headers.items() 40 | if key.lower() not in EXCLUDED_HEADERS 41 | ] 42 | 43 | headers.append(("Access-Control-Allow-Origin", "*")) 44 | 45 | return flask.Response(response.content, response.status_code, headers) 46 | 47 | 48 | @app.route("/") 49 | def proxy(path): 50 | response = requests.get(f"{_ANGULAR_URL.value}{path}") 51 | 52 | headers = [ 53 | (key, value) 54 | for (key, value) in response.raw.headers.items() 55 | if key.lower() not in EXCLUDED_HEADERS 56 | ] 57 | 58 | headers.append(("Access-Control-Allow-Origin", "*")) 59 | 60 | return flask.Response(response.content, response.status_code, headers) 61 | 62 | 63 | @app.after_request 64 | def cors(response): 65 | header = response.headers 66 | header["Access-Control-Allow-Origin"] = "http://localhost:39183" 67 | header["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PUT, DELETE" 68 | header["Access-Control-Allow-Headers"] = "Content-Type" 69 | header["Access-Control-Allow-Credentials"] = "true" 70 | 71 | return response 72 | 73 | 74 | def main(argv): 75 | app.run(host="::", port=_PORT.value, debug=True) 76 | 77 | 78 | if __name__ == "__main__": 79 | absl_app.run(main) 80 | -------------------------------------------------------------------------------- /web/page0/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ -------------------------------------------------------------------------------- /web/page0/src/version.ts: -------------------------------------------------------------------------------- 1 | export const BUILD_VERSION = 'Page 0'; 2 | -------------------------------------------------------------------------------- /web/page0/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.ts", 13 | "src/**/*.d.ts" 14 | ], 15 | "exclude": [ 16 | "src/**/*.spec.ts" 17 | ], 18 | "compilerOptions": { 19 | "composite": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /web/page0/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./node_modules/gts/tsconfig-google.json", 4 | "include": ["src/**/*.ts", "test/**/*.ts"], 5 | "compileOnSave": false, 6 | "compilerOptions": { 7 | "baseUrl": "./", 8 | "outDir": "./dist/out-tsc", 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "noImplicitOverride": true, 12 | "noPropertyAccessFromIndexSignature": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "sourceMap": true, 16 | "declaration": false, 17 | "downlevelIteration": true, 18 | "experimentalDecorators": true, 19 | "moduleResolution": "node", 20 | "importHelpers": true, 21 | "target": "ES2022", 22 | "module": "ES2022", 23 | "useDefineForClassFields": false, 24 | "lib": ["ES2022", "dom"] 25 | }, 26 | "angularCompilerOptions": { 27 | "enableI18nLegacyMessageIdFormat": false, 28 | "strictInjectionParameters": true, 29 | "strictInputAccessModifiers": true, 30 | "strictTemplates": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /web/page0/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------