├── .github ├── ISSUE_TEMPLATE │ └── issue-template.md └── workflows │ ├── code-style.yml │ └── run-tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENCE ├── README.md ├── UPGRADING.md ├── app.Dockerfile ├── composer.json ├── composer.lock ├── config └── cloud-scheduler.php ├── docker-compose.yml ├── example.png ├── logo.png ├── phpunit.xml ├── schedule-command-example.png ├── src ├── CloudSchedulerException.php ├── CloudSchedulerServiceProvider.php ├── Command.php ├── OpenIdVerificator.php ├── OpenIdVerificatorConcrete.php ├── OpenIdVerificatorFake.php └── TaskHandler.php ├── testbench.yaml ├── tests ├── TaskHandlerTest.php └── TestCase.php ├── use-package-kernel.php └── workbench ├── .env ├── .gitignore ├── app ├── Console │ ├── Commands │ │ ├── TestCommand.php │ │ └── TestCommand2.php │ └── Kernel.php └── Models │ └── .gitkeep ├── database ├── factories │ └── .gitkeep ├── migrations │ └── .gitkeep └── seeders │ ├── .gitkeep │ └── DatabaseSeeder.php ├── resources └── views │ └── .gitkeep └── routes ├── .gitkeep ├── api.php ├── console.php └── web.php /.github/ISSUE_TEMPLATE/issue-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue template 3 | about: Help you create an effective issue and know what to expect. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Thank you for taking the time for reporting a bug, requesting a feature, or letting me know something else about the package. 11 | 12 | I create open-source packages for fun while working full-time and running my own business. That means I don't have as much time left to maintain these packages, build elaborate new features or investigate and fix bugs. If you wish to get a feature or bugfix merged it would be greatly appreciated if you can provide as much info as possible and preferably a Pull Request ready with automated tests. Realistically I check Github a few times a week, and take several days, weeks or sometimes months before finishing features/bugfixes (depending on their size of course). 13 | 14 | Thanks for understanding. 😁 15 | -------------------------------------------------------------------------------- /.github/workflows/code-style.yml: -------------------------------------------------------------------------------- 1 | name: Code style 2 | 3 | on: 4 | push: 5 | paths: 6 | - '**.php' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | php-code-styling: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 5 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | with: 20 | ref: ${{ github.head_ref }} 21 | 22 | - name: Check code style 23 | uses: aglipanci/laravel-pint-action@v2 24 | 25 | - name: Commit changes 26 | uses: stefanzweifel/git-auto-commit-action@v5 27 | with: 28 | commit_message: Apply code style rules -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened, synchronize, labeled] 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | access_check: 11 | runs-on: ubuntu-latest 12 | name: Access check 13 | steps: 14 | - name: Ensure pull-request is safe to run 15 | uses: actions/github-script@v7 16 | with: 17 | github-token: ${{secrets.GITHUB_TOKEN}} 18 | script: | 19 | if (context.eventName === 'schedule') { 20 | return 21 | } 22 | 23 | // If the user that pushed the commit is a maintainer, skip the check 24 | const collaborators = await github.rest.repos.listCollaborators({ 25 | owner: context.repo.owner, 26 | repo: context.repo.repo 27 | }); 28 | 29 | if (collaborators.data.some(c => c.login === context.actor)) { 30 | console.log(`User ${context.actor} is allowed to run tests because they are a collaborator.`); 31 | return 32 | } 33 | 34 | const issue_number = context.issue.number; 35 | const repository = context.repo.repo; 36 | const owner = context.repo.owner; 37 | 38 | const response = await github.rest.issues.listLabelsOnIssue({ 39 | owner, 40 | repo: repository, 41 | issue_number 42 | }); 43 | const labels = response.data.map(label => label.name); 44 | let hasLabel = labels.includes('safe-to-test') 45 | 46 | if (context.payload.action === 'synchronize' && hasLabel) { 47 | hasLabel = false 48 | await github.rest.issues.removeLabel({ 49 | owner, 50 | repo: repository, 51 | issue_number, 52 | name: 'safe-to-test' 53 | }); 54 | } 55 | 56 | if (!hasLabel) { 57 | throw "Action was not authorized. Exiting now." 58 | } 59 | 60 | php-tests: 61 | runs-on: ubuntu-latest 62 | needs: access_check 63 | strategy: 64 | matrix: 65 | payload: 66 | - { laravel: '11.*', php: '8.4', 'testbench': '9.*', collision: '8.*'} 67 | - { laravel: '11.*', php: '8.3', 'testbench': '9.*', collision: '8.*'} 68 | - { laravel: '11.*', php: '8.2', 'testbench': '9.*', collision: '8.*'} 69 | - { laravel: '12.*', php: '8.4', 'testbench': '10.*', collision: '8.*'} 70 | - { laravel: '12.*', php: '8.3', 'testbench': '10.*', collision: '8.*'} 71 | - { laravel: '12.*', php: '8.2', 'testbench': '10.*', collision: '8.*'} 72 | 73 | name: PHP ${{ matrix.payload.php }} - Laravel ${{ matrix.payload.laravel }} 74 | 75 | steps: 76 | - name: Checkout code 77 | uses: actions/checkout@v4 78 | with: 79 | ref: ${{ github.event.pull_request.head.sha }} 80 | 81 | - name: Setup PHP 82 | uses: shivammathur/setup-php@v2 83 | with: 84 | php-version: ${{ matrix.payload.php }} 85 | extensions: mbstring, dom, fileinfo 86 | coverage: none 87 | 88 | - name: Install dependencies 89 | run: | 90 | composer require "laravel/framework:${{ matrix.payload.laravel }}" "orchestra/testbench:${{ matrix.payload.testbench }}" "nunomaduro/collision:${{ matrix.payload.collision }}" --no-interaction --no-update 91 | composer update --prefer-stable --prefer-dist --no-interaction 92 | - name: Execute tests 93 | run: | 94 | php -v | head -n 1 95 | vendor/bin/testbench | head -n 1 96 | composer test 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .idea/ 3 | .DS_Store 4 | .phpunit.result.cache 5 | .phpunit.cache/ 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Releases 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | # 2.0.5 - 2023-12-28 8 | 9 | **Changed** 10 | 11 | - Test package with PHP 8.3 12 | 13 | # 2.0.4 - 2023-10-02 14 | 15 | **Changed** 16 | 17 | - Update phpseclib to 3.x 18 | 19 | # 2.0.3 - 2023-06-06 20 | 21 | **Changed** 22 | 23 | - Downgrade phpseclib back to 2.x 24 | 25 | # 2.0.2 - 2023-06-03 26 | 27 | **Changed** 28 | 29 | - Bump phpseclib to 3.x 30 | 31 | # 2.0.1 - 2022-04-26 32 | 33 | **Fixed** 34 | 35 | - Fix conflicting facade accessor (in #16) 36 | 37 | # 2.0.0 - 2022-04-23 38 | 39 | **Changed** 40 | 41 | - Dropped older PHP and Laravel support 42 | - Bumped dependencies 43 | 44 | ## 1.1.0 - 2022-02-09 45 | 46 | **Changed** 47 | 48 | - Added Laravel 9 support. 49 | 50 | ## 1.0.4 - 2022-02-01 51 | 52 | **Added** 53 | 54 | - Added retry mechanism for Google APIs to try 3 times in case they fail 55 | 56 | ## 1.0.3 - 2022-01-08 57 | 58 | **Changed** 59 | 60 | - Updated Composer dependencies 61 | 62 | ## 1.0.2 - 2020-12-27 63 | 64 | **Fixed** 65 | 66 | - Undefined offset error for closure commands 67 | 68 | ## 1.0.1 - 2020-12-07 69 | 70 | **Fixed** 71 | 72 | - Fixed certificates cached too long 73 | 74 | ## 1.0.0 - 2020-10-11 75 | 76 | **Added** 77 | 78 | - Release of the package 79 | 80 | ## 1.0.0-alpha1 - 2020-06-26 81 | 82 | **Added** 83 | 84 | - Initial release of the package. 85 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stackkit (info@stackkit.io) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![Run tests](https://github.com/stackkit/laravel-google-cloud-scheduler/actions/workflows/run-tests.yml/badge.svg)](https://github.com/stackkit/laravel-google-cloud-scheduler/actions/workflows/run-tests.yml) 4 | Latest Stable Version 5 | Downloads 6 | 7 | Companion packages: Cloud Tasks, Cloud Logging 8 | 9 | # Introduction 10 | 11 | This package allows you to use Google Cloud Scheduler to schedule Laravel commands. 12 | 13 | # How it works 14 | 15 | Cloud Scheduler will make HTTP calls to your application. This package adds an endpoint to your application that accepts these HTTP calls with their payload (an Artisan command) and execute them. 16 | 17 | There are two ways to schedule commands using this package: 18 | 19 |
20 | 1. Schedule the `schedule:run` command 21 | 22 | This is the easiest way to use this package. You can schedule the `schedule:run` command to run every minute. 23 |
24 | 25 |
26 | 2. Schedule commands separately 27 | 28 | If your application does not have commands that should run every minute, you may choose to schedule them individually. 29 | 30 | If the command uses `withoutOverlapping`, `before`, `after`, `onSuccess`, `thenPing`, etc, this package will respect those settings, as long as the command is also scheduled in the console kernel. 31 | 32 | For example, let's say we have to generate a report every day at 3:00 AM. We can schedule the `reports:generate` command to run at 3:00 AM using Cloud Scheduler. After the report is generated, OhDear should be pinged. 33 | 34 | Firstly, schedule the command in Cloud Tasks: 35 | 36 | 37 | 38 | Then, schedule the command in the console kernel: 39 | 40 | ```php 41 | public function schedule(Schedule $schedule) 42 | { 43 | $schedule->command('report:generate') 44 | ->thenPing('https://ohdear.app/ping'); 45 | } 46 | ``` 47 | 48 | The package will pick up on the scheduled settings and ping OhDear after the command has run. 49 |
50 | 51 | # Requirements 52 | 53 | This package requires Laravel 11 or 12. 54 | 55 | # Installation 56 | 57 | 1 - Require the package using Composer 58 | 59 | ```bash 60 | composer require stackkit/laravel-google-cloud-scheduler 61 | ``` 62 | 63 | 2 - Define environment variables 64 | 65 | `CLOUD_SCHEDULER_APP_URL` - This should be the URL defined in the `URL` field of your Cloud Scheduler job. 66 | 67 | `CLOUD_SCHEDULER_SERVICE_ACCOUNT` - The e-mail address of the service account invocating the task. 68 | 69 | Optionally, you may publish the configuration file: 70 | 71 | ```bash 72 | php artisan vendor:publish --tag=cloud-scheduler-config 73 | ``` 74 | 75 | 3 - Ensure PHP executable is in open_basedir. This is required for the package to run Artisan commands. 76 | 77 | How to find the executable: 78 | 79 | ```php 80 | php artisan tinker --execute="(new Symfony\\Component\\Process\\PhpExecutableFinder())->find()" 81 | ``` 82 | 83 | 4 - Optional, but highly recommended: server configuration 84 | 85 | Since Artisan commands are now invoked via an HTTP request, you might encounter issues with timeouts. Here's how to adjust them: 86 | 87 | ```nginx 88 | server { 89 | # other server configuration ... 90 | 91 | location /cloud-scheduler-job { 92 | proxy_connect_timeout 600s; 93 | proxy_read_timeout 600s; 94 | fastcgi_read_timeout 600s; 95 | } 96 | 97 | # other locations and server configuration ... 98 | } 99 | 100 | ``` 101 | 102 | 5 - Optional, but highly recommended: set application `RUNNING_IN_CONSOLE` 103 | 104 | Some Laravel service providers only register their commands if the application is being accessed through the command line (Artisan). Because we are calling Laravel scheduler from a HTTP call, that means some commands may never register, such as the Laravel Scout command: 105 | 106 | ```php 107 | /** 108 | * Bootstrap any application services. 109 | * 110 | * @return void 111 | */ 112 | public function boot() 113 | { 114 | if ($this->app->runningInConsole()) { 115 | $this->commands([ 116 | FlushCommand::class, 117 | ImportCommand::class, 118 | IndexCommand::class, 119 | DeleteIndexCommand::class, 120 | ]); 121 | 122 | $this->publishes([ 123 | __DIR__.'/../config/scout.php' => $this->app['path.config'].DIRECTORY_SEPARATOR.'scout.php', 124 | ]); 125 | } 126 | } 127 | ``` 128 | 129 | To circumvent this, please add the following to `bootstrap/app.php` 130 | 131 | ```php 132 | withRouting( 144 | web: __DIR__.'/../routes/web.php', 145 | commands: __DIR__.'/../routes/console.php', 146 | health: '/up', 147 | ) 148 | ->withMiddleware(function (Middleware $middleware) { 149 | // 150 | }) 151 | ->withExceptions(function (Exceptions $exceptions) { 152 | // 153 | })->create(); 154 | 155 | ``` 156 | 157 | 6 - Optional: whitelist route for maintenance mode 158 | 159 | If you want to allow jobs to keep running if the application is down (`php artisan down`), update the following: 160 | 161 | ```php 162 | return Application::configure(basePath: dirname(__DIR__)) 163 | ->withRouting( 164 | web: __DIR__ . '/../routes/web.php', 165 | commands: __DIR__ . '/../routes/console.php', 166 | health: '/up', 167 | ) 168 | ->withMiddleware(function (Middleware $middleware) { 169 | $middleware->preventRequestsDuringMaintenance( 170 | except: [ 171 | '/cloud-scheduler-job', 172 | ], 173 | ); 174 | }) 175 | ->withExceptions(function (Exceptions $exceptions) { 176 | // 177 | })->create(); 178 | 179 | 180 | ``` 181 | 182 | # Cloud Scheduler Example 183 | 184 | Here is an example job that will run `php artisan schedule:run` every minute. 185 | 186 | These are the most important settings: 187 | - Target must be `HTTP` 188 | - URL and AUD (audience) must be `https://yourdomainname.com/cloud-scheduler-job` 189 | - Auth header must be OIDC token! 190 | 191 | 192 | 193 | # Security 194 | 195 | The job handler requires each request to have an OpenID token. Cloud Scheduler will generate an OpenID token and send it along with the job payload to the handler. 196 | 197 | This package verifies that the token is digitally signed by Google and that it's meant for your application. Only Google Scheduler will be able to call your handler. 198 | 199 | More information about OpenID Connect: 200 | 201 | https://developers.google.com/identity/protocols/oauth2/openid-connect 202 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # From 2.x to 3.x 2 | 3 | Support for Laravel 6, 7, 8, and 9 has been dropped. The minimum supported version is now Laravel 10. 4 | 5 | ## Environment changes (Impact: high) 6 | 7 | Publish the new configuration file: 8 | 9 | ```bash 10 | php artisan vendor:publish --tag=cloud-scheduler-config 11 | ``` 12 | 13 | Change the environment variables names: 14 | 15 | - `STACKKIT_CLOUD_SCHEDULER_APP_URL` -> `CLOUD_SCHEDULER_APP_URL` 16 | 17 | Add the following environment variable: 18 | 19 | - `CLOUD_SCHEDULER_SERVICE_ACCOUNT` - The e-mail address of the service account invocating the task. 20 | -------------------------------------------------------------------------------- /app.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM serversideup/php:8.3-fpm 2 | 3 | USER root 4 | RUN install-php-extensions bcmath 5 | 6 | USER www-data -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stackkit/laravel-google-cloud-scheduler", 3 | "license": "MIT", 4 | "authors": [ 5 | { 6 | "name": "Marick van Tuil", 7 | "email": "info@marickvantuil.nl" 8 | } 9 | ], 10 | "require": { 11 | "ext-json": "*", 12 | "google/cloud-scheduler": "^2.0", 13 | "google/auth": "^v1.29.1", 14 | "laravel/framework": "^11.0|^12.0", 15 | "symfony/cache": "^7.2", 16 | "phpseclib/phpseclib": "^3.0" 17 | }, 18 | "require-dev": { 19 | "orchestra/testbench": "^9.0|^10.0", 20 | "nunomaduro/collision": "^8.0", 21 | "laravel/pint": "^1.14" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Stackkit\\LaravelGoogleCloudScheduler\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "Tests\\": "tests/", 31 | "Workbench\\App\\": "workbench/app/", 32 | "Workbench\\Database\\Factories\\": "workbench/database/factories/", 33 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" 34 | } 35 | }, 36 | "extra": { 37 | "laravel": { 38 | "providers": [ 39 | "Stackkit\\LaravelGoogleCloudScheduler\\CloudSchedulerServiceProvider" 40 | ] 41 | } 42 | }, 43 | "scripts": { 44 | "test": "testbench package:test --ansi", 45 | "post-autoload-dump": [ 46 | "@clear", 47 | "@prepare" 48 | ], 49 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 50 | "prepare": [ 51 | "@php vendor/bin/testbench package:discover --ansi", 52 | "@php use-package-kernel.php" 53 | ], 54 | "build": "@php vendor/bin/testbench workbench:build --ansi", 55 | "serve": [ 56 | "Composer\\Config::disableProcessTimeout", 57 | "@build", 58 | "@php vendor/bin/testbench serve" 59 | ], 60 | "lint": [ 61 | "@php vendor/bin/phpstan analyse" 62 | ], 63 | "test": "testbench package:test", 64 | "l10": [ 65 | "composer require laravel/framework:10.* orchestra/testbench:8.* nunomaduro/collision:7.* --no-interaction --no-update", 66 | "composer update --prefer-stable --prefer-dist --no-interaction" 67 | ], 68 | "l11": [ 69 | "composer require laravel/framework:11.* orchestra/testbench:9.* nunomaduro/collision:8.* --no-interaction --no-update", 70 | "composer update --prefer-stable --prefer-dist --no-interaction" 71 | ] 72 | }, 73 | "minimum-stability": "dev", 74 | "prefer-stable": true 75 | } 76 | -------------------------------------------------------------------------------- /config/cloud-scheduler.php: -------------------------------------------------------------------------------- 1 | env('CLOUD_SCHEDULER_APP_URL'), 5 | 'service_account' => env('CLOUD_SCHEDULER_SERVICE_ACCOUNT'), 6 | 'disable_task_handler' => false, 7 | 'disable_token_verification' => false, 8 | ]; 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: . 5 | dockerfile: app.Dockerfile 6 | volumes: 7 | - .:/var/www/html -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackkit/laravel-google-cloud-scheduler/b2b046c3cd417bca207d2cc84a38146dd0b799af/example.png -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackkit/laravel-google-cloud-scheduler/b2b046c3cd417bca207d2cc84a38146dd0b799af/logo.png -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests/ 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | src/ 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /schedule-command-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackkit/laravel-google-cloud-scheduler/b2b046c3cd417bca207d2cc84a38146dd0b799af/schedule-command-example.png -------------------------------------------------------------------------------- /src/CloudSchedulerException.php: -------------------------------------------------------------------------------- 1 | registerRoutes($router); 13 | $this->registerClient(); 14 | } 15 | 16 | public function register() 17 | { 18 | $this->mergeConfigFrom(__DIR__.'/../config/cloud-scheduler.php', 'cloud-scheduler'); 19 | 20 | $this->publishes([ 21 | __DIR__.'/../config/cloud-scheduler.php' => config_path('cloud-scheduler.php'), 22 | ], 'cloud-scheduler-config'); 23 | } 24 | 25 | private function registerRoutes(Router $router) 26 | { 27 | $router->post('cloud-scheduler-job', [TaskHandler::class, 'handle']); 28 | } 29 | 30 | private function registerClient() 31 | { 32 | $this->app->bind('open-id-verificator-gcs', OpenIdVerificatorConcrete::class); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Command.php: -------------------------------------------------------------------------------- 1 | getContent(); 10 | } 11 | 12 | public function captureWithoutArtisan() 13 | { 14 | return trim(str_replace('php artisan', '', $this->capture())); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/OpenIdVerificator.php: -------------------------------------------------------------------------------- 1 | accessToken = $accessToken; 15 | } 16 | 17 | public function verify(?string $token, array $config): void 18 | { 19 | if (! $token) { 20 | throw new CloudSchedulerException('Missing [Authorization] header'); 21 | } 22 | 23 | $payload = $this->accessToken->verify( 24 | $token, 25 | [ 26 | 'audience' => config('cloud-scheduler.app_url'), 27 | 'throwException' => true, 28 | ] 29 | ); 30 | 31 | if (($payload['email'] ?? '') !== config('cloud-scheduler.service_account')) { 32 | throw new CloudSchedulerException('Invalid service account email'); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/OpenIdVerificatorFake.php: -------------------------------------------------------------------------------- 1 | schedule = $container->make(Schedule::class); 20 | } 21 | 22 | /** 23 | * @throws CloudSchedulerException 24 | */ 25 | public function handle() 26 | { 27 | if (config('cloud-scheduler.disable_task_handler')) { 28 | abort(404); 29 | } 30 | 31 | if (config('cloud-scheduler.disable_token_verification') !== true) { 32 | OpenIdVerificator::verify(request()->bearerToken(), []); 33 | } 34 | 35 | set_time_limit(0); 36 | 37 | $output = $this->runCommand($this->command->captureWithoutArtisan()); 38 | 39 | return $this->cleanOutput($output); 40 | } 41 | 42 | private function runCommand($command) 43 | { 44 | if ($this->isScheduledCommand($command)) { 45 | $scheduledCommand = $this->getScheduledCommand($command); 46 | 47 | if ($scheduledCommand->withoutOverlapping && ! $scheduledCommand->mutex->create($scheduledCommand)) { 48 | return null; 49 | } 50 | 51 | $scheduledCommand->callBeforeCallbacks($this->container); 52 | 53 | Artisan::call($command); 54 | 55 | $scheduledCommand->callAfterCallbacks($this->container); 56 | } else { 57 | Artisan::call($command); 58 | } 59 | 60 | return Artisan::output(); 61 | } 62 | 63 | private function isScheduledCommand($command) 64 | { 65 | return ! is_null($this->getScheduledCommand($command)); 66 | } 67 | 68 | private function getScheduledCommand($command) 69 | { 70 | $events = $this->schedule->events(); 71 | 72 | foreach ($events as $event) { 73 | if (! is_string($event->command)) { 74 | continue; 75 | } 76 | 77 | $eventCommand = $this->commandWithoutArtisan($event->command); 78 | 79 | if ($command === $eventCommand) { 80 | return $event; 81 | } 82 | } 83 | 84 | return null; 85 | } 86 | 87 | private function commandWithoutArtisan($command) 88 | { 89 | $parts = explode(ARTISAN_BINARY, $command); 90 | 91 | return substr($parts[1], 2, strlen($parts[1])); 92 | } 93 | 94 | private function cleanOutput($output) 95 | { 96 | return trim($output); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /testbench.yaml: -------------------------------------------------------------------------------- 1 | providers: 2 | - Stackkit\LaravelGoogleCloudScheduler\CloudSchedulerServiceProvider 3 | 4 | migrations: 5 | - workbench/database/migrations 6 | 7 | seeders: 8 | - Workbench\Database\Seeders\DatabaseSeeder 9 | 10 | workbench: 11 | start: '/' 12 | install: true 13 | discovers: 14 | web: true 15 | api: false 16 | commands: true 17 | components: false 18 | views: false 19 | build: [] 20 | assets: [] 21 | sync: [] 22 | -------------------------------------------------------------------------------- /tests/TaskHandlerTest.php: -------------------------------------------------------------------------------- 1 | call('POST', '/cloud-scheduler-job', content: 'php artisan env')->content(); 20 | 21 | // Assert 22 | $this->assertStringContainsString('The application environment is [testing]', $output); 23 | } 24 | 25 | #[Test] 26 | public function it_requires_a_jwt() 27 | { 28 | // Act 29 | $response = $this->call('POST', '/cloud-scheduler-job', content: 'php artisan env'); 30 | 31 | // Assert 32 | $this->assertStringContainsString('Missing [Authorization] header', $response->content()); 33 | $response->assertStatus(500); 34 | 35 | } 36 | 37 | #[Test] 38 | public function it_requires_a_jwt_signed_by_google() 39 | { 40 | // Act 41 | $response = $this 42 | ->withToken('hey') 43 | ->call('POST', '/cloud-scheduler-job', server: ['HTTP_AUTHORIZATION' => 'Bearer 123'], content: 'php artisan env'); 44 | 45 | // Assert 46 | $this->assertStringContainsString('Wrong number of segments', $response->content()); 47 | $response->assertStatus(500); 48 | } 49 | 50 | #[Test] 51 | public function it_prevents_overlapping_if_the_command_is_scheduled_without_overlapping() 52 | { 53 | OpenIdVerificator::fake(); 54 | Event::fake(); 55 | 56 | cache()->clear(); 57 | 58 | $this->assertLoggedLines(0); 59 | 60 | $this->call('POST', '/cloud-scheduler-job', content: 'php artisan test:command'); 61 | 62 | $this->assertLoggedLines(1); 63 | $this->assertLogged('TestCommand'); 64 | 65 | $mutex = head(app(Schedule::class)->events())->mutexName(); 66 | 67 | cache()->add($mutex, true, 60); 68 | 69 | $this->call('POST', '/cloud-scheduler-job', content: 'php artisan test:command'); 70 | 71 | $this->assertLoggedLines(1); 72 | 73 | cache()->delete($mutex); 74 | 75 | $this->call('POST', '/cloud-scheduler-job', content: 'php artisan test:command'); 76 | 77 | $this->assertLoggedLines(2); 78 | } 79 | 80 | #[Test] 81 | public function it_runs_the_before_and_after_callbacks() 82 | { 83 | OpenIdVerificator::fake(); 84 | 85 | $this->call('POST', '/cloud-scheduler-job', content: 'php artisan test:command2'); 86 | 87 | $this->assertLoggedLines(3); 88 | $this->assertLogged('log after'); 89 | $this->assertLogged('log before'); 90 | $this->assertLogged('TestCommand2'); 91 | } 92 | 93 | #[Test] 94 | public function it_can_run_the_schedule_run_command() 95 | { 96 | OpenIdVerificator::fake(); 97 | 98 | $this->call('POST', '/cloud-scheduler-job', content: 'php artisan schedule:run'); 99 | 100 | $this->assertLoggedLines(5); 101 | $this->assertLogged('TestCommand'); 102 | $this->assertLogged('TestCommand2'); 103 | $this->assertLogged('log call'); 104 | $this->assertLogged('log after'); 105 | $this->assertLogged('log before'); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | resetLog(); 23 | 24 | cache()->clear(); 25 | } 26 | 27 | public function assertLogged(string $message): void 28 | { 29 | $log = file_get_contents(storage_path('log.txt')); 30 | $this->assertStringContainsString($message, $log); 31 | } 32 | 33 | public function assertLoggedLines(int $lines): void 34 | { 35 | $log = file_get_contents(storage_path('log.txt')); 36 | $this->assertCount($lines, array_filter(explode("\n", $log))); 37 | } 38 | 39 | public function resetLog(): void 40 | { 41 | file_put_contents(storage_path('log.txt'), ''); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /use-package-kernel.php: -------------------------------------------------------------------------------- 1 | command(TestCommand::class)->withoutOverlapping()->everyMinute(); 19 | $schedule->command('test:command2')->before(function () { 20 | file_put_contents(storage_path('log.txt'), 'log after'.PHP_EOL, FILE_APPEND); 21 | })->after(function () { 22 | file_put_contents(storage_path('log.txt'), 'log before'.PHP_EOL, FILE_APPEND); 23 | }); 24 | $schedule->call(function () { 25 | file_put_contents(storage_path('log.txt'), 'log call'.PHP_EOL, FILE_APPEND); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /workbench/app/Models/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackkit/laravel-google-cloud-scheduler/b2b046c3cd417bca207d2cc84a38146dd0b799af/workbench/app/Models/.gitkeep -------------------------------------------------------------------------------- /workbench/database/factories/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackkit/laravel-google-cloud-scheduler/b2b046c3cd417bca207d2cc84a38146dd0b799af/workbench/database/factories/.gitkeep -------------------------------------------------------------------------------- /workbench/database/migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackkit/laravel-google-cloud-scheduler/b2b046c3cd417bca207d2cc84a38146dd0b799af/workbench/database/migrations/.gitkeep -------------------------------------------------------------------------------- /workbench/database/seeders/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stackkit/laravel-google-cloud-scheduler/b2b046c3cd417bca207d2cc84a38146dd0b799af/workbench/database/seeders/.gitkeep -------------------------------------------------------------------------------- /workbench/database/seeders/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | get('/user', function (Request $request) { 18 | // return $request->user(); 19 | // }); 20 | -------------------------------------------------------------------------------- /workbench/routes/console.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 19 | // })->purpose('Display an inspiring quote'); 20 | -------------------------------------------------------------------------------- /workbench/routes/web.php: -------------------------------------------------------------------------------- 1 |