granny-devops 0.17.4__tar.gz → 0.18.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {granny_devops-0.17.4 → granny_devops-0.18.1}/PKG-INFO +23 -2
- {granny_devops-0.17.4 → granny_devops-0.18.1}/README.md +22 -1
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/__init__.py +1 -1
- granny_devops-0.18.1/granny/cli/knative.py +378 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/main.py +26 -0
- granny_devops-0.18.1/granny/cli/oauth.py +164 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/serverless.py +46 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/credentials/secrets.py +9 -1
- granny_devops-0.18.1/granny/knative/__init__.py +5 -0
- granny_devops-0.18.1/granny/knative/client.py +339 -0
- granny_devops-0.18.1/granny/oauth/__init__.py +19 -0
- granny_devops-0.18.1/granny/oauth/productivity.py +237 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/pyproject.toml +1 -1
- granny_devops-0.17.4/granny/cli/oauth.py +0 -96
- {granny_devops-0.17.4 → granny_devops-0.18.1}/.gitignore +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/LICENSE +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/airtable/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/airtable/client.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/costs.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/credits.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/gpu_pricing.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/kubernetes.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/authentik/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/authentik/client.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/authentik/provision.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/aws/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/aws/bedrock.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/aws/quota.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/_client.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/account.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/deployment.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/openai.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/quota.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/resourcegroup.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/role.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/sku.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/vm.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/azure/webapp.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/airtable.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/analyze.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/authentik.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/aws.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/azure.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/cdn.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/create.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/credentials.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/dns.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/docker.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/edge.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/elk.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/email.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/indexing.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/storage.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cli/zitadel.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/letsencrypt_cert.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/registrars.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/base.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/bunny.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/desec.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/factory.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/inwx.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/manual.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/dns/records.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/docker/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/docker/build_base.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/edge/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/edge/bunny.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/elk/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/elk/client.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/email/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/email/mailjet.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/email/stalwart.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/email/workmail.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/indexing/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/indexing/google.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/indexing/indexnow.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/report.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/storage/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/storage/aws.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/storage/bunny.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/storage/hetzner.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/zitadel/__init__.py +0 -0
- {granny_devops-0.17.4 → granny_devops-0.18.1}/granny/zitadel/client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: granny-devops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.18.1
|
|
4
4
|
Summary: Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation
|
|
5
5
|
Author-email: Martin Wieser <martin.wieser@pseekoo.com>
|
|
6
6
|
License: MIT License
|
|
@@ -219,6 +219,8 @@ Common keys:
|
|
|
219
219
|
| Cloudflare | `CLOUDFLARE_API_TOKEN` |
|
|
220
220
|
| Hetzner | `HETZNER_S3_ACCESS_KEY`, `HETZNER_S3_SECRET_KEY`, `HETZNER_DNS_API_TOKEN` |
|
|
221
221
|
| Scaleway | `SCW_ACCESS_KEY`, `SCW_SECRET_KEY`, `SCW_DEFAULT_PROJECT_ID` |
|
|
222
|
+
| Knative FaaS API | `OBACHAN_API_KEY`, `OBACHAN_API_BASE_URL` |
|
|
223
|
+
| Obachan OpenBao | `OBACHAN_BAO_ADDR`, `OBACHAN_BAO_TOKEN` |
|
|
222
224
|
| Mailjet | `MAILJET_API_KEY`, `MAILJET_SECRET_KEY` |
|
|
223
225
|
| deSEC | `DESEC_API_TOKEN` |
|
|
224
226
|
| ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
|
|
@@ -234,6 +236,12 @@ what's configured at any time.
|
|
|
234
236
|
```shell
|
|
235
237
|
granny --help # see every command group
|
|
236
238
|
granny <group> --help # drill into one
|
|
239
|
+
|
|
240
|
+
# Provision mail/calendar OAuth clients to a dashboard env and Obachan OpenBao
|
|
241
|
+
granny oauth provision-productivity --env-file ../dashboard/.deploy.env \
|
|
242
|
+
--obachan-env staging --obachan-app dashboard \
|
|
243
|
+
--google-client-json client_secret.json \
|
|
244
|
+
--azure-client-id "$AZURE_CLIENT_ID" --azure-client-secret "$AZURE_CLIENT_SECRET"
|
|
237
245
|
```
|
|
238
246
|
|
|
239
247
|
The CLI is organized by capability, not by provider. You pick the
|
|
@@ -301,6 +309,16 @@ granny docker build-base --image myapp-base --hash-file requirements.txt
|
|
|
301
309
|
|
|
302
310
|
# Scaleway FaaS
|
|
303
311
|
granny serverless deploy my-fn --source-dir ./dist --namespace my-app
|
|
312
|
+
granny serverless delete-function my-fn --namespace my-app --yes
|
|
313
|
+
|
|
314
|
+
# Knative FaaS control API (`granny faas` is an alias)
|
|
315
|
+
granny knative list
|
|
316
|
+
granny knative deploy my-fn --image registry.example.com/my-fn:latest \
|
|
317
|
+
--env LOG_LEVEL=info --min-scale 0 --max-scale 5
|
|
318
|
+
granny knative scale my-fn --max-scale 10
|
|
319
|
+
granny knative logs my-fn --tail 100
|
|
320
|
+
granny knative invoke my-fn --method POST --path /process --body '{"job": 42}'
|
|
321
|
+
granny knative remove my-fn --yes
|
|
304
322
|
|
|
305
323
|
# Mailjet + WorkMail
|
|
306
324
|
granny email mailjet setup-dns example.com
|
|
@@ -391,7 +409,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
|
|
|
391
409
|
| CDN | Bunny |
|
|
392
410
|
| Edge scripting | Bunny |
|
|
393
411
|
| Object storage | AWS S3, Bunny Storage, Hetzner S3 |
|
|
394
|
-
| Serverless functions | Scaleway FaaS, Scaleway Containers |
|
|
412
|
+
| Serverless functions | Scaleway FaaS, Scaleway Containers, Knative FaaS control API |
|
|
395
413
|
| Workers / KV / D1 / R2 | Cloudflare |
|
|
396
414
|
| Record search and endpoint maintenance | Airtable |
|
|
397
415
|
| Email send infra | Mailjet, AWS SES, AWS WorkMail |
|
|
@@ -402,6 +420,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
|
|
|
402
420
|
| Observability admin | Elasticsearch / Kibana native-user management |
|
|
403
421
|
| Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
|
|
404
422
|
| Search indexing | IndexNow (Bing/Yandex/Seznam/Naver/Yep), Google Indexing API |
|
|
423
|
+
| Productivity OAuth | Google Cloud and Microsoft Entra mail/calendar client provisioning |
|
|
405
424
|
|
|
406
425
|
## As a library
|
|
407
426
|
|
|
@@ -444,6 +463,8 @@ granny/
|
|
|
444
463
|
elk/ Elasticsearch / Kibana security user management
|
|
445
464
|
email/ Mailjet, WorkMail, SES forwarding
|
|
446
465
|
indexing/ IndexNow + Google Indexing API
|
|
466
|
+
knative/ Knative FaaS control API client
|
|
467
|
+
oauth/ Google/Microsoft productivity OAuth provisioning
|
|
447
468
|
serverless/ Scaleway FaaS
|
|
448
469
|
storage/ Object storage (AWS / Bunny / Hetzner)
|
|
449
470
|
```
|
|
@@ -85,6 +85,8 @@ Common keys:
|
|
|
85
85
|
| Cloudflare | `CLOUDFLARE_API_TOKEN` |
|
|
86
86
|
| Hetzner | `HETZNER_S3_ACCESS_KEY`, `HETZNER_S3_SECRET_KEY`, `HETZNER_DNS_API_TOKEN` |
|
|
87
87
|
| Scaleway | `SCW_ACCESS_KEY`, `SCW_SECRET_KEY`, `SCW_DEFAULT_PROJECT_ID` |
|
|
88
|
+
| Knative FaaS API | `OBACHAN_API_KEY`, `OBACHAN_API_BASE_URL` |
|
|
89
|
+
| Obachan OpenBao | `OBACHAN_BAO_ADDR`, `OBACHAN_BAO_TOKEN` |
|
|
88
90
|
| Mailjet | `MAILJET_API_KEY`, `MAILJET_SECRET_KEY` |
|
|
89
91
|
| deSEC | `DESEC_API_TOKEN` |
|
|
90
92
|
| ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
|
|
@@ -100,6 +102,12 @@ what's configured at any time.
|
|
|
100
102
|
```shell
|
|
101
103
|
granny --help # see every command group
|
|
102
104
|
granny <group> --help # drill into one
|
|
105
|
+
|
|
106
|
+
# Provision mail/calendar OAuth clients to a dashboard env and Obachan OpenBao
|
|
107
|
+
granny oauth provision-productivity --env-file ../dashboard/.deploy.env \
|
|
108
|
+
--obachan-env staging --obachan-app dashboard \
|
|
109
|
+
--google-client-json client_secret.json \
|
|
110
|
+
--azure-client-id "$AZURE_CLIENT_ID" --azure-client-secret "$AZURE_CLIENT_SECRET"
|
|
103
111
|
```
|
|
104
112
|
|
|
105
113
|
The CLI is organized by capability, not by provider. You pick the
|
|
@@ -167,6 +175,16 @@ granny docker build-base --image myapp-base --hash-file requirements.txt
|
|
|
167
175
|
|
|
168
176
|
# Scaleway FaaS
|
|
169
177
|
granny serverless deploy my-fn --source-dir ./dist --namespace my-app
|
|
178
|
+
granny serverless delete-function my-fn --namespace my-app --yes
|
|
179
|
+
|
|
180
|
+
# Knative FaaS control API (`granny faas` is an alias)
|
|
181
|
+
granny knative list
|
|
182
|
+
granny knative deploy my-fn --image registry.example.com/my-fn:latest \
|
|
183
|
+
--env LOG_LEVEL=info --min-scale 0 --max-scale 5
|
|
184
|
+
granny knative scale my-fn --max-scale 10
|
|
185
|
+
granny knative logs my-fn --tail 100
|
|
186
|
+
granny knative invoke my-fn --method POST --path /process --body '{"job": 42}'
|
|
187
|
+
granny knative remove my-fn --yes
|
|
170
188
|
|
|
171
189
|
# Mailjet + WorkMail
|
|
172
190
|
granny email mailjet setup-dns example.com
|
|
@@ -257,7 +275,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
|
|
|
257
275
|
| CDN | Bunny |
|
|
258
276
|
| Edge scripting | Bunny |
|
|
259
277
|
| Object storage | AWS S3, Bunny Storage, Hetzner S3 |
|
|
260
|
-
| Serverless functions | Scaleway FaaS, Scaleway Containers |
|
|
278
|
+
| Serverless functions | Scaleway FaaS, Scaleway Containers, Knative FaaS control API |
|
|
261
279
|
| Workers / KV / D1 / R2 | Cloudflare |
|
|
262
280
|
| Record search and endpoint maintenance | Airtable |
|
|
263
281
|
| Email send infra | Mailjet, AWS SES, AWS WorkMail |
|
|
@@ -268,6 +286,7 @@ granny indexing google https://example.com/job-posting --action URL_UPDATED
|
|
|
268
286
|
| Observability admin | Elasticsearch / Kibana native-user management |
|
|
269
287
|
| Azure ops | Identity, subscriptions, ARM deployments, Cognitive Services (Azure OpenAI), App Service, VM sizes, VM inventory/deallocate/delete, RBAC role assignment, compute quota |
|
|
270
288
|
| Search indexing | IndexNow (Bing/Yandex/Seznam/Naver/Yep), Google Indexing API |
|
|
289
|
+
| Productivity OAuth | Google Cloud and Microsoft Entra mail/calendar client provisioning |
|
|
271
290
|
|
|
272
291
|
## As a library
|
|
273
292
|
|
|
@@ -310,6 +329,8 @@ granny/
|
|
|
310
329
|
elk/ Elasticsearch / Kibana security user management
|
|
311
330
|
email/ Mailjet, WorkMail, SES forwarding
|
|
312
331
|
indexing/ IndexNow + Google Indexing API
|
|
332
|
+
knative/ Knative FaaS control API client
|
|
333
|
+
oauth/ Google/Microsoft productivity OAuth provisioning
|
|
313
334
|
serverless/ Scaleway FaaS
|
|
314
335
|
storage/ Object storage (AWS / Bunny / Hetzner)
|
|
315
336
|
```
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Knative function management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
def knative() -> None:
|
|
13
|
+
"""Manage functions through a Knative FaaS control API."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Private helpers
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _guess_content_type(path: str) -> str:
|
|
22
|
+
"""Guess Content-Type from file extension."""
|
|
23
|
+
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
24
|
+
return {
|
|
25
|
+
"html": "text/html; charset=utf-8",
|
|
26
|
+
"css": "text/css; charset=utf-8",
|
|
27
|
+
"js": "application/javascript; charset=utf-8",
|
|
28
|
+
"json": "application/json; charset=utf-8",
|
|
29
|
+
"png": "image/png",
|
|
30
|
+
"jpg": "image/jpeg",
|
|
31
|
+
"jpeg": "image/jpeg",
|
|
32
|
+
"gif": "image/gif",
|
|
33
|
+
"svg": "image/svg+xml",
|
|
34
|
+
"webp": "image/webp",
|
|
35
|
+
"ico": "image/x-icon",
|
|
36
|
+
"woff": "font/woff",
|
|
37
|
+
"woff2": "font/woff2",
|
|
38
|
+
"ttf": "font/ttf",
|
|
39
|
+
"xml": "application/xml",
|
|
40
|
+
"txt": "text/plain",
|
|
41
|
+
}.get(ext, "application/octet-stream")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Commands
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@knative.command("deploy")
|
|
50
|
+
@click.argument("name")
|
|
51
|
+
@click.option(
|
|
52
|
+
"--image", required=True, help="Container image ref (e.g. registry/fn:latest)"
|
|
53
|
+
)
|
|
54
|
+
@click.option(
|
|
55
|
+
"--env",
|
|
56
|
+
"-e",
|
|
57
|
+
"env_vars",
|
|
58
|
+
multiple=True,
|
|
59
|
+
help="Environment variable in KEY=VAL format. Repeatable.",
|
|
60
|
+
)
|
|
61
|
+
@click.option("--min-scale", default=0, show_default=True, type=int)
|
|
62
|
+
@click.option("--max-scale", default=5, show_default=True, type=int)
|
|
63
|
+
@click.option(
|
|
64
|
+
"--api-url",
|
|
65
|
+
envvar="OBACHAN_API_BASE_URL",
|
|
66
|
+
default=None,
|
|
67
|
+
help="FaaS control API base URL. Defaults to OBACHAN_API_BASE_URL env or vault.",
|
|
68
|
+
)
|
|
69
|
+
@click.option(
|
|
70
|
+
"--api-key",
|
|
71
|
+
envvar="OBACHAN_API_KEY",
|
|
72
|
+
default=None,
|
|
73
|
+
help="API key. Defaults to OBACHAN_API_KEY env or vault.",
|
|
74
|
+
)
|
|
75
|
+
def knative_deploy(
|
|
76
|
+
name: str,
|
|
77
|
+
image: str,
|
|
78
|
+
env_vars: tuple,
|
|
79
|
+
min_scale: int,
|
|
80
|
+
max_scale: int,
|
|
81
|
+
api_url: str | None,
|
|
82
|
+
api_key: str | None,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Deploy (create or update) a Knative function."""
|
|
85
|
+
from granny.knative import KnativeClient
|
|
86
|
+
|
|
87
|
+
parsed: dict[str, str] = {}
|
|
88
|
+
for entry in env_vars:
|
|
89
|
+
k, sep, v = entry.partition("=")
|
|
90
|
+
if sep:
|
|
91
|
+
parsed[k.strip()] = v
|
|
92
|
+
else:
|
|
93
|
+
click.echo(f"Warning: skipping malformed env entry: {entry!r}", err=True)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
c = KnativeClient(base_url=api_url, api_key=api_key)
|
|
97
|
+
result = c.deploy(
|
|
98
|
+
name, image, env_vars=parsed, min_scale=min_scale, max_scale=max_scale
|
|
99
|
+
)
|
|
100
|
+
except (ValueError, RuntimeError) as exc:
|
|
101
|
+
click.echo(f"Error: {exc}", err=True)
|
|
102
|
+
raise SystemExit(1)
|
|
103
|
+
|
|
104
|
+
url = (result.get("status") or {}).get("url", "(not yet ready)")
|
|
105
|
+
click.echo(f"Deployed: {name}")
|
|
106
|
+
click.echo(f"URL: {url}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@knative.command("list")
|
|
110
|
+
@click.option("--api-url", envvar="OBACHAN_API_BASE_URL", default=None)
|
|
111
|
+
@click.option("--api-key", envvar="OBACHAN_API_KEY", default=None)
|
|
112
|
+
def knative_list(api_url: str | None, api_key: str | None) -> None:
|
|
113
|
+
"""List deployed Knative functions."""
|
|
114
|
+
from granny.knative import KnativeClient
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
c = KnativeClient(base_url=api_url, api_key=api_key)
|
|
118
|
+
items = c.list()
|
|
119
|
+
except (ValueError, RuntimeError) as exc:
|
|
120
|
+
click.echo(f"Error: {exc}", err=True)
|
|
121
|
+
raise SystemExit(1)
|
|
122
|
+
|
|
123
|
+
if not items:
|
|
124
|
+
click.echo("No functions deployed.")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
click.echo(f"{'NAME':<30} {'URL'}")
|
|
128
|
+
for item in items:
|
|
129
|
+
item_name = (item.get("metadata") or {}).get("name") or item.get("name") or "?"
|
|
130
|
+
url = (item.get("status") or {}).get("url", "-")
|
|
131
|
+
click.echo(f"{item_name:<30} {url}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@knative.command("get")
|
|
135
|
+
@click.argument("name")
|
|
136
|
+
@click.option("--api-url", envvar="OBACHAN_API_BASE_URL", default=None)
|
|
137
|
+
@click.option("--api-key", envvar="OBACHAN_API_KEY", default=None)
|
|
138
|
+
def knative_get(name: str, api_url: str | None, api_key: str | None) -> None:
|
|
139
|
+
"""Get details of a Knative function."""
|
|
140
|
+
from granny.knative import KnativeClient
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
c = KnativeClient(base_url=api_url, api_key=api_key)
|
|
144
|
+
result = c.get(name)
|
|
145
|
+
except (ValueError, RuntimeError) as exc:
|
|
146
|
+
click.echo(f"Error: {exc}", err=True)
|
|
147
|
+
raise SystemExit(1)
|
|
148
|
+
|
|
149
|
+
if result is None:
|
|
150
|
+
click.echo(f"Function '{name}' not found.", err=True)
|
|
151
|
+
raise SystemExit(1)
|
|
152
|
+
|
|
153
|
+
click.echo(json.dumps(result, indent=2))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@knative.command("scale")
|
|
157
|
+
@click.argument("name")
|
|
158
|
+
@click.option("--min-scale", default=None, type=int)
|
|
159
|
+
@click.option("--max-scale", default=None, type=int)
|
|
160
|
+
@click.option("--api-url", envvar="OBACHAN_API_BASE_URL", default=None)
|
|
161
|
+
@click.option("--api-key", envvar="OBACHAN_API_KEY", default=None)
|
|
162
|
+
def knative_scale(
|
|
163
|
+
name: str,
|
|
164
|
+
min_scale: int | None,
|
|
165
|
+
max_scale: int | None,
|
|
166
|
+
api_url: str | None,
|
|
167
|
+
api_key: str | None,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Update the scale bounds of a Knative function."""
|
|
170
|
+
if min_scale is None and max_scale is None:
|
|
171
|
+
raise click.UsageError(
|
|
172
|
+
"At least one of --min-scale or --max-scale must be provided."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
from granny.knative import KnativeClient
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
c = KnativeClient(base_url=api_url, api_key=api_key)
|
|
179
|
+
c.scale(name, min_scale=min_scale, max_scale=max_scale)
|
|
180
|
+
except (ValueError, RuntimeError) as exc:
|
|
181
|
+
click.echo(f"Error: {exc}", err=True)
|
|
182
|
+
raise SystemExit(1)
|
|
183
|
+
|
|
184
|
+
click.echo(f"Scaled {name}: min={min_scale} max={max_scale}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@knative.command("remove")
|
|
188
|
+
@click.argument("name")
|
|
189
|
+
@click.option(
|
|
190
|
+
"--yes",
|
|
191
|
+
is_flag=True,
|
|
192
|
+
help="Confirm deletion. Required to prevent accidental removal.",
|
|
193
|
+
)
|
|
194
|
+
@click.option("--api-url", envvar="OBACHAN_API_BASE_URL", default=None)
|
|
195
|
+
@click.option("--api-key", envvar="OBACHAN_API_KEY", default=None)
|
|
196
|
+
def knative_remove(
|
|
197
|
+
name: str,
|
|
198
|
+
yes: bool,
|
|
199
|
+
api_url: str | None,
|
|
200
|
+
api_key: str | None,
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Remove a deployed Knative function."""
|
|
203
|
+
if not yes:
|
|
204
|
+
raise click.UsageError(
|
|
205
|
+
"Deletion requires --yes flag to confirm. "
|
|
206
|
+
"Pass --yes to confirm removal of the function."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
from granny.knative import KnativeClient
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
c = KnativeClient(base_url=api_url, api_key=api_key)
|
|
213
|
+
c.delete(name)
|
|
214
|
+
except (ValueError, RuntimeError) as exc:
|
|
215
|
+
click.echo(f"Error: {exc}", err=True)
|
|
216
|
+
raise SystemExit(1)
|
|
217
|
+
|
|
218
|
+
click.echo(f"Removed: {name}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@knative.command("logs")
|
|
222
|
+
@click.argument("name")
|
|
223
|
+
@click.option("--tail", default=50, show_default=True, type=int)
|
|
224
|
+
@click.option("--api-url", envvar="OBACHAN_API_BASE_URL", default=None)
|
|
225
|
+
@click.option("--api-key", envvar="OBACHAN_API_KEY", default=None)
|
|
226
|
+
def knative_logs(
|
|
227
|
+
name: str,
|
|
228
|
+
tail: int,
|
|
229
|
+
api_url: str | None,
|
|
230
|
+
api_key: str | None,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Retrieve recent log lines for a Knative function."""
|
|
233
|
+
from granny.knative import KnativeClient
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
c = KnativeClient(base_url=api_url, api_key=api_key)
|
|
237
|
+
log_text = c.logs(name, tail=tail)
|
|
238
|
+
except (ValueError, RuntimeError) as exc:
|
|
239
|
+
click.echo(f"Error: {exc}", err=True)
|
|
240
|
+
raise SystemExit(1)
|
|
241
|
+
|
|
242
|
+
click.echo(log_text)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@knative.command("invoke")
|
|
246
|
+
@click.argument("name")
|
|
247
|
+
@click.option("--method", default="GET", show_default=True)
|
|
248
|
+
@click.option("--path", "invoke_path", default="/", show_default=True)
|
|
249
|
+
@click.option("--body", default=None, help="Request body string")
|
|
250
|
+
@click.option("--api-url", envvar="OBACHAN_API_BASE_URL", default=None)
|
|
251
|
+
@click.option("--api-key", envvar="OBACHAN_API_KEY", default=None)
|
|
252
|
+
def knative_invoke(
|
|
253
|
+
name: str,
|
|
254
|
+
method: str,
|
|
255
|
+
invoke_path: str,
|
|
256
|
+
body: str | None,
|
|
257
|
+
api_url: str | None,
|
|
258
|
+
api_key: str | None,
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Invoke a Knative function via the control-plane proxy."""
|
|
261
|
+
from granny.knative import KnativeClient
|
|
262
|
+
|
|
263
|
+
body_bytes = body.encode() if body else None
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
c = KnativeClient(base_url=api_url, api_key=api_key)
|
|
267
|
+
result = c.invoke(name, method=method, path=invoke_path, body=body_bytes)
|
|
268
|
+
except (ValueError, RuntimeError) as exc:
|
|
269
|
+
click.echo(f"Error: {exc}", err=True)
|
|
270
|
+
raise SystemExit(1)
|
|
271
|
+
|
|
272
|
+
click.echo(result.decode("utf-8", errors="replace"))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@knative.command("deploy-ui")
|
|
276
|
+
@click.argument("name")
|
|
277
|
+
@click.argument(
|
|
278
|
+
"dist_dir",
|
|
279
|
+
type=click.Path(exists=True, file_okay=False, resolve_path=True),
|
|
280
|
+
)
|
|
281
|
+
@click.option("--image", required=True, help="Backend container image ref")
|
|
282
|
+
@click.option("--zone", required=True, help="Bunny Storage zone name")
|
|
283
|
+
@click.option(
|
|
284
|
+
"--storage-password",
|
|
285
|
+
envvar="BUNNY_STORAGE_PASSWORD",
|
|
286
|
+
required=True,
|
|
287
|
+
help="Bunny Storage zone password.",
|
|
288
|
+
)
|
|
289
|
+
@click.option("--hostname", default="storage.bunnycdn.com", show_default=True)
|
|
290
|
+
@click.option(
|
|
291
|
+
"--pull-zone-id",
|
|
292
|
+
default=None,
|
|
293
|
+
type=int,
|
|
294
|
+
help="Bunny CDN pull zone ID for cache purge after upload.",
|
|
295
|
+
)
|
|
296
|
+
@click.option(
|
|
297
|
+
"--env", "-e", "env_vars", multiple=True, help="Backend env vars KEY=VAL."
|
|
298
|
+
)
|
|
299
|
+
@click.option("--api-url", envvar="OBACHAN_API_BASE_URL", default=None)
|
|
300
|
+
@click.option("--api-key", envvar="OBACHAN_API_KEY", default=None)
|
|
301
|
+
def knative_deploy_ui(
|
|
302
|
+
name: str,
|
|
303
|
+
dist_dir: str,
|
|
304
|
+
image: str,
|
|
305
|
+
zone: str,
|
|
306
|
+
storage_password: str,
|
|
307
|
+
hostname: str,
|
|
308
|
+
pull_zone_id: int | None,
|
|
309
|
+
env_vars: tuple,
|
|
310
|
+
api_url: str | None,
|
|
311
|
+
api_key: str | None,
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Deploy a UI to Bunny Storage and a backend function to Knative.
|
|
314
|
+
|
|
315
|
+
Uploads all files in DIST_DIR to Bunny Storage (with Cache-Control headers
|
|
316
|
+
appropriate for SPA assets), optionally purges the CDN cache, then deploys
|
|
317
|
+
the backend image as a Knative service.
|
|
318
|
+
"""
|
|
319
|
+
from granny.storage.bunny import BunnyStorageClient
|
|
320
|
+
|
|
321
|
+
storage_client = BunnyStorageClient()
|
|
322
|
+
uploaded = 0
|
|
323
|
+
errors = 0
|
|
324
|
+
|
|
325
|
+
for root, _dirs, files in os.walk(dist_dir):
|
|
326
|
+
for fname in files:
|
|
327
|
+
local = os.path.join(root, fname)
|
|
328
|
+
remote = os.path.relpath(local, dist_dir).replace("\\", "/")
|
|
329
|
+
ct = _guess_content_type(local)
|
|
330
|
+
cc = (
|
|
331
|
+
"no-cache, no-store, must-revalidate"
|
|
332
|
+
if fname.lower().endswith(".html")
|
|
333
|
+
else "public, max-age=31536000, immutable"
|
|
334
|
+
)
|
|
335
|
+
try:
|
|
336
|
+
with open(local, "rb") as fh:
|
|
337
|
+
storage_client.upload_file(
|
|
338
|
+
hostname,
|
|
339
|
+
storage_password,
|
|
340
|
+
zone,
|
|
341
|
+
remote,
|
|
342
|
+
fh.read(),
|
|
343
|
+
ct,
|
|
344
|
+
cache_control=cc,
|
|
345
|
+
)
|
|
346
|
+
uploaded += 1
|
|
347
|
+
except Exception as exc:
|
|
348
|
+
click.echo(f" ERROR uploading {remote}: {exc}", err=True)
|
|
349
|
+
errors += 1
|
|
350
|
+
|
|
351
|
+
click.echo(f"Uploaded {uploaded} files to Bunny zone '{zone}' ({errors} errors).")
|
|
352
|
+
if errors:
|
|
353
|
+
raise SystemExit(1)
|
|
354
|
+
|
|
355
|
+
if pull_zone_id is not None:
|
|
356
|
+
from granny.cdn.bunny import BunnyCDNClient
|
|
357
|
+
|
|
358
|
+
BunnyCDNClient().purge_cache(pull_zone_id)
|
|
359
|
+
click.echo(f"CDN cache purged for pull zone {pull_zone_id}.")
|
|
360
|
+
|
|
361
|
+
parsed_env: dict[str, str] = {}
|
|
362
|
+
for entry in env_vars:
|
|
363
|
+
k, sep, v = entry.partition("=")
|
|
364
|
+
if sep:
|
|
365
|
+
parsed_env[k.strip()] = v
|
|
366
|
+
|
|
367
|
+
from granny.knative import KnativeClient
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
c = KnativeClient(base_url=api_url, api_key=api_key)
|
|
371
|
+
result = c.deploy(name, image, env_vars=parsed_env)
|
|
372
|
+
except (ValueError, RuntimeError) as exc:
|
|
373
|
+
click.echo(f"Error deploying {name}: {exc}", err=True)
|
|
374
|
+
raise SystemExit(1)
|
|
375
|
+
|
|
376
|
+
url = (result.get("status") or {}).get("url", "(not yet ready)")
|
|
377
|
+
click.echo(f"Deployed: {name}")
|
|
378
|
+
click.echo(f"URL: {url}")
|
|
@@ -38,65 +38,78 @@ def cli(verbose: bool, quiet: bool) -> None:
|
|
|
38
38
|
|
|
39
39
|
# Register command groups -------------------------------------------------
|
|
40
40
|
|
|
41
|
+
|
|
41
42
|
def _register_commands() -> None:
|
|
42
43
|
"""Register available command groups."""
|
|
43
44
|
try:
|
|
44
45
|
from granny.cli.credentials import credentials
|
|
46
|
+
|
|
45
47
|
cli.add_command(credentials)
|
|
46
48
|
except ImportError:
|
|
47
49
|
pass
|
|
48
50
|
try:
|
|
49
51
|
from granny.cli.analyze import analyze
|
|
52
|
+
|
|
50
53
|
cli.add_command(analyze)
|
|
51
54
|
except ImportError:
|
|
52
55
|
pass
|
|
53
56
|
try:
|
|
54
57
|
from granny.cli.cdn import cdn
|
|
58
|
+
|
|
55
59
|
cli.add_command(cdn)
|
|
56
60
|
except ImportError:
|
|
57
61
|
pass
|
|
58
62
|
try:
|
|
59
63
|
from granny.cli.create import create
|
|
64
|
+
|
|
60
65
|
cli.add_command(create)
|
|
61
66
|
except ImportError:
|
|
62
67
|
pass
|
|
63
68
|
try:
|
|
64
69
|
from granny.cli.docker import docker
|
|
70
|
+
|
|
65
71
|
cli.add_command(docker)
|
|
66
72
|
except ImportError:
|
|
67
73
|
pass
|
|
68
74
|
try:
|
|
69
75
|
from granny.cli.dns import dns
|
|
76
|
+
|
|
70
77
|
cli.add_command(dns)
|
|
71
78
|
except ImportError:
|
|
72
79
|
pass
|
|
73
80
|
try:
|
|
74
81
|
from granny.cli.cloudflare import cloudflare
|
|
82
|
+
|
|
75
83
|
cli.add_command(cloudflare)
|
|
76
84
|
except ImportError:
|
|
77
85
|
pass
|
|
78
86
|
try:
|
|
79
87
|
from granny.cli.storage import storage
|
|
88
|
+
|
|
80
89
|
cli.add_command(storage)
|
|
81
90
|
except ImportError:
|
|
82
91
|
pass
|
|
83
92
|
try:
|
|
84
93
|
from granny.cli.edge import edge
|
|
94
|
+
|
|
85
95
|
cli.add_command(edge)
|
|
86
96
|
except ImportError:
|
|
87
97
|
pass
|
|
88
98
|
try:
|
|
89
99
|
from granny.cli.serverless import serverless
|
|
100
|
+
|
|
90
101
|
cli.add_command(serverless)
|
|
91
102
|
except ImportError:
|
|
92
103
|
pass
|
|
93
104
|
try:
|
|
94
105
|
from granny.cli.email import email
|
|
106
|
+
|
|
95
107
|
cli.add_command(email)
|
|
96
108
|
except ImportError:
|
|
97
109
|
pass
|
|
98
110
|
try:
|
|
99
111
|
from granny.cli.authentik import authentik
|
|
112
|
+
|
|
100
113
|
cli.add_command(authentik)
|
|
101
114
|
except ImportError:
|
|
102
115
|
pass
|
|
@@ -107,34 +120,47 @@ def _register_commands() -> None:
|
|
|
107
120
|
pass
|
|
108
121
|
try:
|
|
109
122
|
from granny.cli.elk import elk
|
|
123
|
+
|
|
110
124
|
cli.add_command(elk)
|
|
111
125
|
except ImportError:
|
|
112
126
|
pass
|
|
113
127
|
try:
|
|
114
128
|
from granny.cli.azure import azure
|
|
129
|
+
|
|
115
130
|
cli.add_command(azure)
|
|
116
131
|
except ImportError:
|
|
117
132
|
pass
|
|
118
133
|
try:
|
|
119
134
|
from granny.cli.aws import aws
|
|
135
|
+
|
|
120
136
|
cli.add_command(aws)
|
|
121
137
|
except ImportError:
|
|
122
138
|
pass
|
|
123
139
|
try:
|
|
124
140
|
from granny.cli.indexing import indexing
|
|
141
|
+
|
|
125
142
|
cli.add_command(indexing)
|
|
126
143
|
except ImportError:
|
|
127
144
|
pass
|
|
128
145
|
try:
|
|
129
146
|
from granny.cli.zitadel import zitadel
|
|
147
|
+
|
|
130
148
|
cli.add_command(zitadel)
|
|
131
149
|
except ImportError:
|
|
132
150
|
pass
|
|
133
151
|
try:
|
|
134
152
|
from granny.cli.oauth import oauth
|
|
153
|
+
|
|
135
154
|
cli.add_command(oauth)
|
|
136
155
|
except ImportError:
|
|
137
156
|
pass
|
|
157
|
+
try:
|
|
158
|
+
from granny.cli.knative import knative
|
|
159
|
+
|
|
160
|
+
cli.add_command(knative)
|
|
161
|
+
cli.add_command(knative, name="faas")
|
|
162
|
+
except ImportError:
|
|
163
|
+
pass
|
|
138
164
|
|
|
139
165
|
|
|
140
166
|
_register_commands()
|