@zenifra/cli 0.5.0 → 0.6.0
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.
- package/README.md +28 -5
- package/bin/zenifra.mjs +601 -34
- package/examples/job-project.json +20 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,6 +59,7 @@ zenifra git runtimes
|
|
|
59
59
|
zenifra git connections
|
|
60
60
|
zenifra git repositories resolve --connection <connection-id> --path equipe/aplicacao
|
|
61
61
|
zenifra git branches --connection <connection-id> --repository <repository-id>
|
|
62
|
+
zenifra plans --type job
|
|
62
63
|
zenifra create project
|
|
63
64
|
zenifra create project --name <name> --plan free --payment-mode hourly --config @examples/http-project.json
|
|
64
65
|
zenifra create project --name <name> --plan basic --payment-mode hourly --config @examples/http-github-project.json
|
|
@@ -70,6 +71,7 @@ zenifra create project --name <name> --plan analytics-starter --payment-mode hou
|
|
|
70
71
|
zenifra create project --name <name> --plan db-free --payment-mode hourly --config @examples/valkey-key-value-project.json
|
|
71
72
|
zenifra create project --name <name> --plan cache-free --payment-mode hourly --config @examples/valkey-cache-project.json
|
|
72
73
|
zenifra create project --name <name> --plan queue-free --payment-mode hourly --config @examples/valkey-queue-project.json
|
|
74
|
+
zenifra create project --name nightly-report --plan job-basic --payment-mode per_minute --config @examples/job-project.json
|
|
73
75
|
zenifra projects --type http --page 1 --limit 15
|
|
74
76
|
zenifra projects --type valkey --page 1 --limit 15
|
|
75
77
|
zenifra project info --project <project-id>
|
|
@@ -82,6 +84,9 @@ zenifra valkey credentials rotate --project <project-id> --wait
|
|
|
82
84
|
zenifra valkey credentials status --project <project-id> --operation <operation-id>
|
|
83
85
|
zenifra project url --project <project-id>
|
|
84
86
|
zenifra project logs --project <project-id> --instance <instance-id>
|
|
87
|
+
zenifra project runs --project <project-id> --page 1 --limit 20
|
|
88
|
+
zenifra project runs cancel --project <project-id> --run <run-id>
|
|
89
|
+
zenifra project runs logs --project <project-id> --run <run-id>
|
|
85
90
|
zenifra project metrics --project <project-id> --instance <instance-id>
|
|
86
91
|
zenifra project metrics capabilities --project <project-id>
|
|
87
92
|
zenifra project network --project <project-id> --view summary
|
|
@@ -275,7 +280,7 @@ zenifra plans --type database
|
|
|
275
280
|
zenifra plans --type storage --json
|
|
276
281
|
```
|
|
277
282
|
|
|
278
|
-
`zenifra plans` funciona sem autenticacao e mostra os catalogos publicos de HTTP, banco, armazenamento e Valkey. Use `--type valkey` para consultar Key Value, Cache e Queue.
|
|
283
|
+
`zenifra plans` funciona sem autenticacao e mostra os catalogos publicos de HTTP, banco, armazenamento, Jobs agendados e Valkey. Use `--type job` para consultar Jobs agendados ou `--type valkey` para consultar Key Value, Cache e Queue.
|
|
279
284
|
|
|
280
285
|
Para planos HTTP, a saida legivel tambem mostra as capacidades disponiveis, como logs, metricas, verificacao de saude, auto-scaling, subdominio personalizado e acesso de rede. Use `--json` quando precisar consumir o catalogo sem formatacao.
|
|
281
286
|
|
|
@@ -353,6 +358,7 @@ Use os arquivos em `examples/` como base para `zenifra create project`:
|
|
|
353
358
|
- `examples/valkey-key-value-project.json`: projeto Valkey Key Value com armazenamento persistente
|
|
354
359
|
- `examples/valkey-cache-project.json`: projeto Valkey Cache sem armazenamento persistente
|
|
355
360
|
- `examples/valkey-queue-project.json`: projeto Valkey Queue com armazenamento persistente
|
|
361
|
+
- `examples/job-project.json`: Job agendado com cron UTC, imagem e armazenamento efêmero
|
|
356
362
|
|
|
357
363
|
Se voce rodar apenas `zenifra create project`, a CLI abre um wizard interativo estilo `npm init` e pergunta todos os campos guiados. Cada pergunta mostra:
|
|
358
364
|
|
|
@@ -366,17 +372,18 @@ O wizard atual cobre:
|
|
|
366
372
|
- projetos `postgresql`
|
|
367
373
|
- projetos `mariadb`
|
|
368
374
|
- projetos `valkey` nos perfis `key_value`, `cache` e `queue`
|
|
375
|
+
- Jobs agendados com uma imagem OCI pronta
|
|
369
376
|
|
|
370
377
|
Projetos `clickhouse` usam atualmente o fluxo nao interativo com `--config`, como em `examples/clickhouse-project.json`.
|
|
371
378
|
|
|
372
|
-
`zenifra create project` nao assume valores default para `--plan` e `--payment-mode
|
|
379
|
+
`zenifra create project` nao assume valores default para `--plan` e `--payment-mode`, exceto para Jobs, que usam `per_minute` automaticamente.
|
|
373
380
|
Configs HTTP nao interativas tambem devem informar `config.exposure`; use `public` para criar rota/dominio publico ou `private` para manter a aplicacao sem exposicao na internet.
|
|
374
381
|
Antes de escolher um plano com o usuario, compare os catalogos com `zenifra plans` para evitar suposicoes sobre custo.
|
|
375
382
|
|
|
376
383
|
Valores aceitos:
|
|
377
384
|
|
|
378
|
-
- `payment_mode`: `hourly`, `monthly`, `yearly`
|
|
379
|
-
- `type_project` no `config`: `http`, `postgresql`, `mariadb`, `valkey`, `clickhouse`
|
|
385
|
+
- `payment_mode`: `hourly`, `monthly`, `yearly`, `per_minute` (somente Jobs)
|
|
386
|
+
- `type_project` no `config`: `http`, `postgresql`, `mariadb`, `valkey`, `clickhouse`, `job`
|
|
380
387
|
- `exposure` no `config` HTTP: `public`, `private`
|
|
381
388
|
- `plan`: consulte `zenifra plans` para os planos atuais; ClickHouse usa `analytics-*`; Valkey usa `db-*` para Key Value, `cache-*` para Cache e `queue-*` para Queue
|
|
382
389
|
- `config.profile` em projetos Valkey: `key_value`, `cache` ou `queue`
|
|
@@ -387,6 +394,7 @@ Valores aceitos:
|
|
|
387
394
|
- `config.github.version_deploy`: use `enabled: true`, `event: "tag"` ou `"release"` e um `tag_pattern` explicito; `include_prereleases` e opcional e padrao `false`
|
|
388
395
|
- `config.source` e `config.build` (quando houver origem Git por conexao): use os IDs retornados pela configuracao segura no Console/API; nao informe credenciais Git nesses campos
|
|
389
396
|
- `config.autoscaling` (somente HTTP pago): `enabled: true`, `max_instances` maior ou igual a `config.instances` e alvos opcionais de CPU/memoria entre 1 e 100
|
|
397
|
+
- `config.job` (somente Jobs): cron com cinco campos em UTC; o wizard usa a imagem OCI pronta e nao pergunta comando, argumentos, URL, exposicao, porta ou instancias
|
|
390
398
|
|
|
391
399
|
Observacoes do wizard:
|
|
392
400
|
|
|
@@ -397,7 +405,8 @@ Observacoes do wizard:
|
|
|
397
405
|
- em projetos de banco, o wizard nao pergunta `username`, `password` nem `database name`
|
|
398
406
|
- em projetos de banco, a CLI preenche apenas campos tecnicos minimos exigidos pela validacao atual da API
|
|
399
407
|
- em projetos Valkey, a capacidade é definida pelo plano e a CLI não pergunta instâncias, imagem, variáveis de ambiente ou exposição HTTP
|
|
400
|
-
- a
|
|
408
|
+
- em Jobs, a imagem OCI pronta é obrigatória, o cron usa cinco campos em UTC, a cobrança é por minuto inteiro e a CLI não pergunta origem GitHub, tipo de pagamento, comando, argumentos, exposição HTTP, porta ou instâncias
|
|
409
|
+
- a conexão mascarada pode ser consultada a qualquer momento; a credencial completa aparece apenas na criação ou em uma rotação concluída, podendo ser salva em arquivo privado com `--connection-file <path>`
|
|
401
410
|
- `valkey credentials rotate` retorna uma operação assíncrona; use `--wait` ou `valkey credentials status` para acompanhar
|
|
402
411
|
|
|
403
412
|
Exemplo de entrega segura para automação local:
|
|
@@ -408,6 +417,20 @@ zenifra valkey credentials rotate --project <project-id> --wait --connection-fil
|
|
|
408
417
|
|
|
409
418
|
O arquivo é criado com permissão privada; a conexão não aparece na saída do comando quando essa opção é usada.
|
|
410
419
|
|
|
420
|
+
## Execuções de Jobs agendados
|
|
421
|
+
|
|
422
|
+
Consulte o histórico de execuções e os logs de uma execução específica:
|
|
423
|
+
|
|
424
|
+
```bash
|
|
425
|
+
zenifra project runs --project <project-id>
|
|
426
|
+
zenifra project runs cancel --project <project-id> --run <run-id>
|
|
427
|
+
zenifra project runs logs --project <project-id> --run <run-id>
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
`project runs cancel` cancela somente a execução selecionada. O cron do projeto continua agendando novas execuções; use `zenifra project stop --project <project-id>` para pausar o projeto e interromper execuções futuras.
|
|
431
|
+
|
|
432
|
+
O cancelamento aguarda até 30 segundos pela finalização segura e pode forçar o encerramento depois desse período. Se uma execução antiga não puder ser cancelada com segurança, o comando informa o problema e você deve aguardar que ela termine ou alcance o limite de tempo.
|
|
433
|
+
|
|
411
434
|
## Regressao manual de auto-scaling em staging
|
|
412
435
|
|
|
413
436
|
O teste de staging cria projetos, gera trafego, consulta consumo e remove somente os projetos criados pela propria execucao. Ele exige uma API de teste explicita, rejeita a API de producao e exige habilitacao explicita das mutacoes. O exemplo abaixo usa uma API local de teste.
|
package/bin/zenifra.mjs
CHANGED
|
@@ -27,6 +27,9 @@ const PROFILES_FILE = join(SESSION_DIR, 'profiles.json');
|
|
|
27
27
|
const PROFILE_STORE_VERSION = 1;
|
|
28
28
|
const DEFAULT_PROFILE_NAME = 'default';
|
|
29
29
|
const DEFAULT_HTTP_TIMEOUT_MS = 300_000;
|
|
30
|
+
const OCI_IMAGE_REFERENCE_PATTERN = /^[a-z0-9._-]+\/(?:[a-z0-9._-]+\/)*[a-z0-9._-]+(?::[a-z0-9._-]+|@sha256:[a-f0-9]{64})$/;
|
|
31
|
+
const OCI_IMAGE_REFERENCE_MIN_LENGTH = 8;
|
|
32
|
+
const OCI_IMAGE_REFERENCE_MAX_LENGTH = 256;
|
|
30
33
|
const KNOWN_FLAG_NAMES = new Set([
|
|
31
34
|
'apiBase',
|
|
32
35
|
'oauth',
|
|
@@ -74,6 +77,8 @@ const KNOWN_FLAG_NAMES = new Set([
|
|
|
74
77
|
'project',
|
|
75
78
|
'repository',
|
|
76
79
|
'revoke',
|
|
80
|
+
'run',
|
|
81
|
+
'runId',
|
|
77
82
|
'showValues',
|
|
78
83
|
'status',
|
|
79
84
|
'tagPattern',
|
|
@@ -115,8 +120,8 @@ const ALLOWED_PLAN_VALUES = new Set([
|
|
|
115
120
|
'queue-premium',
|
|
116
121
|
'queue-enterprise',
|
|
117
122
|
]);
|
|
118
|
-
const ALLOWED_PAYMENT_MODE_VALUES = new Set(['hourly', 'monthly', 'yearly']);
|
|
119
|
-
const ALLOWED_TYPE_PROJECT_VALUES = new Set(['http', 'postgresql', 'mariadb', 'valkey', 'clickhouse']);
|
|
123
|
+
const ALLOWED_PAYMENT_MODE_VALUES = new Set(['hourly', 'monthly', 'yearly', 'per_minute']);
|
|
124
|
+
const ALLOWED_TYPE_PROJECT_VALUES = new Set(['http', 'postgresql', 'mariadb', 'valkey', 'clickhouse', 'job']);
|
|
120
125
|
const ALLOWED_RUNTIME_VALUES = new Set(['nodejs', 'python']);
|
|
121
126
|
const ALLOWED_GITHUB_VERSION_DEPLOY_EVENTS = new Set(['tag', 'release']);
|
|
122
127
|
const ALLOWED_GITHUB_VERSION_DEPLOY_FIELDS = new Set(['enabled', 'event', 'tag_pattern', 'include_prereleases']);
|
|
@@ -171,6 +176,7 @@ const WIZARD_DATABASE_PLAN_OPTIONS = [
|
|
|
171
176
|
'db-free',
|
|
172
177
|
];
|
|
173
178
|
const WIZARD_PAYMENT_MODE_OPTIONS = ['hourly', 'monthly', 'yearly'];
|
|
179
|
+
const WIZARD_JOB_PLAN_OPTIONS = WIZARD_HTTP_PLAN_OPTIONS.map((plan) => `job-${plan}`);
|
|
174
180
|
const WIZARD_TYPE_PROJECT_OPTIONS = ['http', 'postgresql', 'mariadb', 'valkey'];
|
|
175
181
|
const WIZARD_VALKEY_PROFILE_OPTIONS = ['key_value', 'cache', 'queue'];
|
|
176
182
|
const WIZARD_HTTP_EXPOSURE_OPTIONS = ['public', 'private'];
|
|
@@ -212,15 +218,15 @@ Usage:
|
|
|
212
218
|
zenifra whoami [--json]
|
|
213
219
|
zenifra orgs [--json]
|
|
214
220
|
zenifra org set [--org <id>]
|
|
215
|
-
zenifra plans [--type <all|http|database|storage|valkey>] [--json]
|
|
221
|
+
zenifra plans [--type <all|http|database|storage|valkey|job>] [--json]
|
|
216
222
|
zenifra git providers [--json]
|
|
217
223
|
zenifra git runtimes [--json]
|
|
218
224
|
zenifra git connections [--json]
|
|
219
225
|
zenifra git repositories resolve --connection <id> --path <owner/repo> [--json]
|
|
220
226
|
zenifra git branches --connection <id> --repository <opaque-id> [--json]
|
|
221
227
|
zenifra create project
|
|
222
|
-
zenifra create project --name <name> --plan <plan> --payment-mode <mode> --config <json|@file> [--description <text>] [--org <id>] [--json]
|
|
223
|
-
zenifra projects [--json] [--org <id>] [--type <http|postgresql|mariadb|valkey>] [--page <n>] [--limit <n>]
|
|
228
|
+
zenifra create project --name <name> --plan <plan> [--payment-mode <mode>] --config <json|@file> [--description <text>] [--org <id>] [--json]
|
|
229
|
+
zenifra projects [--json] [--org <id>] [--type <http|postgresql|mariadb|valkey|job>] [--page <n>] [--limit <n>]
|
|
224
230
|
zenifra valkey status --project <id> [--json]
|
|
225
231
|
zenifra valkey connection --project <id> [--json]
|
|
226
232
|
zenifra valkey credentials rotate --project <id> [--idempotency-key <key>] [--wait] [--interval <seconds>] [--timeout <seconds>] [--json]
|
|
@@ -231,6 +237,9 @@ Usage:
|
|
|
231
237
|
zenifra project delete --project <id> --yes [--json]
|
|
232
238
|
zenifra project url --project <id> [--json]
|
|
233
239
|
zenifra project logs --project <id> [--instance <id>] [--json]
|
|
240
|
+
zenifra project runs --project <id> [--page <n>] [--limit <n>] [--json]
|
|
241
|
+
zenifra project runs cancel --project <id> --run <id> [--json]
|
|
242
|
+
zenifra project runs logs --project <id> --run <id> [--json]
|
|
234
243
|
zenifra project metrics --project <id> [--instance <id>] [--json]
|
|
235
244
|
zenifra project metrics capabilities --project <id> [--json]
|
|
236
245
|
zenifra project healthcheck get --project <id> [--json]
|
|
@@ -413,10 +422,10 @@ const HELP_SPECS = [
|
|
|
413
422
|
},
|
|
414
423
|
{
|
|
415
424
|
command: 'plans',
|
|
416
|
-
usage: 'zenifra plans [--type <all|http|database|storage|valkey>] [--json]',
|
|
417
|
-
description: 'Lista os catalogos publicos de preco de planos HTTP, banco, armazenamento e
|
|
418
|
-
flags: ['--type <type> Filtra o catalogo: all, http, database, storage, valkey, key-value, cache ou queue.', '--json Imprime a resposta em JSON.'],
|
|
419
|
-
examples: ['zenifra plans', 'zenifra plans --type http', 'zenifra plans --type valkey --json'],
|
|
425
|
+
usage: 'zenifra plans [--type <all|http|database|storage|valkey|job>] [--json]',
|
|
426
|
+
description: 'Lista os catalogos publicos de preco de planos HTTP, banco, armazenamento, Valkey e Jobs agendados.',
|
|
427
|
+
flags: ['--type <type> Filtra o catalogo: all, http, database, storage, valkey, job, key-value, cache ou queue.', '--json Imprime a resposta em JSON.'],
|
|
428
|
+
examples: ['zenifra plans', 'zenifra plans --type http', 'zenifra plans --type valkey --json', 'zenifra plans --type job --json'],
|
|
420
429
|
output: 'HTTP\nPlano Hora Mes Ano Recursos\nfree R$ 0,00 R$ 0,00 R$ 0,00 1 GB Armazenamento Efemero',
|
|
421
430
|
jsonOutput: '{"http":[{"plan":"free","prices":{"hourly":0,"monthly":0,"yearly":0},"features":["1 GB Armazenamento Efemero"]}],"database":[],"storage":[]}',
|
|
422
431
|
},
|
|
@@ -533,7 +542,7 @@ const HELP_SPECS = [
|
|
|
533
542
|
},
|
|
534
543
|
{
|
|
535
544
|
command: 'projects',
|
|
536
|
-
usage: 'zenifra projects [--json] [--org <id>] [--type <http|postgresql|mariadb|valkey>] [--page <n>] [--limit <n>]',
|
|
545
|
+
usage: 'zenifra projects [--json] [--org <id>] [--type <http|postgresql|mariadb|valkey|job>] [--page <n>] [--limit <n>]',
|
|
537
546
|
description: 'Lista projetos da organizacao ativa ou da API key.',
|
|
538
547
|
flags: ['--json Imprime a resposta em JSON.', '--org <id> Usa uma organizacao especifica com sessao de usuario.', '--type <type> Filtra por tipo de projeto.', '--page <n> Pagina. Padrao: 1.', '--limit <n> Itens por pagina. Padrao: 15.'],
|
|
539
548
|
examples: ['zenifra projects --type http --page 1 --limit 15', 'zenifra projects --json'],
|
|
@@ -542,9 +551,9 @@ const HELP_SPECS = [
|
|
|
542
551
|
},
|
|
543
552
|
{
|
|
544
553
|
command: 'create project',
|
|
545
|
-
usage: 'zenifra create project\n zenifra create project --name <name> --plan <plan> --payment-mode <mode> --config <json|@file> [--description <text>] [--idempotency-key <key>] [--org <id>] [--json]',
|
|
554
|
+
usage: 'zenifra create project\n zenifra create project --name <name> --plan <plan> [--payment-mode <mode>] --config <json|@file> [--description <text>] [--idempotency-key <key>] [--org <id>] [--json]',
|
|
546
555
|
description: 'Cria um projeto via wizard interativo ou via payload de configuracao JSON.',
|
|
547
|
-
flags: ['--name <name> Nome do projeto.', '--plan <plan> Plano do projeto.', '--payment-mode <mode>
|
|
556
|
+
flags: ['--name <name> Nome do projeto.', '--plan <plan> Plano do projeto.', '--payment-mode <mode> Opcional para Jobs; demais tipos seguem a validacao do catalogo.', '--config <json|@file> JSON inline ou arquivo.', '--description <text> Descricao opcional.', '--idempotency-key <key> Chave para repetir a mesma criacao com seguranca.', '--json Imprime a resposta em JSON.'],
|
|
548
557
|
examples: ['zenifra create project', 'zenifra create project --name api-web --plan free --payment-mode hourly --config @examples/http-project.json'],
|
|
549
558
|
output: 'Campo Valor\n------- --------------------------------------\nProjeto 507f1f77bcf86cd799439012\nDominio https://api-web.clients.zenifra.com',
|
|
550
559
|
jsonOutput: '{"status":"success","data":{"id":"507f1f77bcf86cd799439012","name":"api-web"}}',
|
|
@@ -552,11 +561,11 @@ const HELP_SPECS = [
|
|
|
552
561
|
},
|
|
553
562
|
{
|
|
554
563
|
command: 'project',
|
|
555
|
-
usage: 'zenifra project\n zenifra project info --project <id> [--json]\n zenifra project source --project <id> [--json]\n zenifra project source branches --project <id> [--json]\n zenifra project source deploy-settings set --project <id> --mode <manual|branch|tag|release> [--tag-pattern <pattern>] [--include-prereleases <true|false>] [--json]\n zenifra project stop --project <id> [--json]\n zenifra project resume --project <id> [--json]\n zenifra project delete --project <id> --yes [--json]\n zenifra project url --project <id> [--json]\n zenifra project logs --project <id> [--instance <id>] [--json]\n zenifra project metrics --project <id> [--instance <id>] [--json]\n zenifra project metrics capabilities --project <id> [--json]\n zenifra project healthcheck get --project <id> [--json]\n zenifra project healthcheck set --project <id> --path /health [--json]\n zenifra project healthcheck disable --project <id> [--json]\n zenifra project healthcheck failures --project <id> [--page <n>] [--limit <n>] [--json]\n zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]\n zenifra project image set --project <id> --image <image> [--json]\n zenifra project exposure set --project <id> --exposure <public|private> [--json]\n zenifra project envs --project <id> [--json] [--show-values]\n zenifra project env add --project <id> --name <name> --value <value> [--json]\n zenifra project env update --project <id> --name <name> --value <value> [--json]\n zenifra project env remove --project <id> --name <name> [--json]\n zenifra project autoscaling --project <id> [--json]\n zenifra project autoscaling set --project <id> --min <n> --max <n> [--cpu <percent>] [--memory <percent>] [--json]\n zenifra project autoscaling disable --project <id> [--json]\n zenifra project autoscaling events --project <id> [--direction <scale_up|scale_down>] [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]\n zenifra project billing usage --project <id> [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]\n zenifra project instances --project <id> [--json]\n zenifra project instances set --project <id> --count <n> [--json]',
|
|
564
|
+
usage: 'zenifra project\n zenifra project info --project <id> [--json]\n zenifra project source --project <id> [--json]\n zenifra project source branches --project <id> [--json]\n zenifra project source deploy-settings set --project <id> --mode <manual|branch|tag|release> [--tag-pattern <pattern>] [--include-prereleases <true|false>] [--json]\n zenifra project stop --project <id> [--json]\n zenifra project resume --project <id> [--json]\n zenifra project delete --project <id> --yes [--json]\n zenifra project url --project <id> [--json]\n zenifra project logs --project <id> [--instance <id>] [--json]\n zenifra project runs --project <id> [--page <n>] [--limit <n>] [--json]\n zenifra project runs cancel --project <id> --run <id> [--json]\n zenifra project runs logs --project <id> --run <id> [--json]\n zenifra project metrics --project <id> [--instance <id>] [--json]\n zenifra project metrics capabilities --project <id> [--json]\n zenifra project healthcheck get --project <id> [--json]\n zenifra project healthcheck set --project <id> --path /health [--json]\n zenifra project healthcheck disable --project <id> [--json]\n zenifra project healthcheck failures --project <id> [--page <n>] [--limit <n>] [--json]\n zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]\n zenifra project image set --project <id> --image <image> [--json]\n zenifra project exposure set --project <id> --exposure <public|private> [--json]\n zenifra project envs --project <id> [--json] [--show-values]\n zenifra project env add --project <id> --name <name> --value <value> [--json]\n zenifra project env update --project <id> --name <name> --value <value> [--json]\n zenifra project env remove --project <id> --name <name> [--json]\n zenifra project autoscaling --project <id> [--json]\n zenifra project autoscaling set --project <id> --min <n> --max <n> [--cpu <percent>] [--memory <percent>] [--json]\n zenifra project autoscaling disable --project <id> [--json]\n zenifra project autoscaling events --project <id> [--direction <scale_up|scale_down>] [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]\n zenifra project billing usage --project <id> [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]\n zenifra project instances --project <id> [--json]\n zenifra project instances set --project <id> --count <n> [--json]',
|
|
556
565
|
description: 'Agrupa comandos operacionais e de introspecao sobre um projeto especifico.',
|
|
557
566
|
examples: ['zenifra project', 'zenifra project info --project proj_1', 'zenifra project source --project proj_1', 'zenifra project env add --project proj_1 --name NODE_ENV --value production'],
|
|
558
567
|
output: 'Zenifra CLI - project',
|
|
559
|
-
notes: ['Use "zenifra help project <subcomando>" para detalhes de info, stop, resume, delete, url, logs, metrics, capabilities, network, image, github, source, exposure, autoscaling, billing, envs e instances.'],
|
|
568
|
+
notes: ['Use "zenifra help project <subcomando>" para detalhes de info, stop, resume, delete, url, logs, runs, cancelamento, metrics, capabilities, network, image, github, source, exposure, autoscaling, billing, envs e instances.'],
|
|
560
569
|
},
|
|
561
570
|
{
|
|
562
571
|
command: 'project source',
|
|
@@ -698,6 +707,38 @@ const HELP_SPECS = [
|
|
|
698
707
|
jsonOutput: '"server started\\nGET /health 200"',
|
|
699
708
|
notes: ['Nao faz streaming continuo; retorna o snapshot disponibilizado pela API.'],
|
|
700
709
|
},
|
|
710
|
+
{
|
|
711
|
+
command: 'project runs',
|
|
712
|
+
usage: 'zenifra project runs --project <id> [--page <n>] [--limit <n>] [--json]',
|
|
713
|
+
description: 'Lista as execucoes de um Job agendado com o historico de cobranca.',
|
|
714
|
+
flags: ['--project <id> ID do projeto.', '--page <n> Pagina. Padrao: 1.', '--limit <n> Itens por pagina. Maximo: 50.', '--json Imprime a resposta em JSON.'],
|
|
715
|
+
examples: ['zenifra project runs --project 507f1f77bcf86cd799439012', 'zenifra project runs --project 507f1f77bcf86cd799439012 --page 2 --limit 20 --json'],
|
|
716
|
+
output: 'ID Status Agendada Duracao Minutos cobrados Valor\nrun_1 succeeded 2026-09-01T12:00:00.000Z 70 s 2 R$ 0,04',
|
|
717
|
+
jsonOutput: '{"runs":[{"id":"run_1","status":"succeeded","duration_seconds":70,"billed_minutes":2,"plan":"job-basic","currency":"brl","amount":4}],"pagination":{"page":1,"limit":20,"total":1,"total_pages":1}}',
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
command: 'project runs cancel',
|
|
721
|
+
usage: 'zenifra project runs cancel --project <id> --run <id> [--json]',
|
|
722
|
+
description: 'Cancela somente a execucao selecionada de um Job e cobra somente o tempo consumido.',
|
|
723
|
+
flags: ['--project <id> ID do projeto.', '--run <id> ID publico da execucao.', '--json Imprime a resposta em JSON.'],
|
|
724
|
+
examples: ['zenifra project runs cancel --project 507f1f77bcf86cd799439012 --run run_1'],
|
|
725
|
+
output: 'Execucao: cancelled',
|
|
726
|
+
jsonOutput: '{"run":{"id":"run_1","status":"cancelled","billed_minutes":2}}',
|
|
727
|
+
notes: [
|
|
728
|
+
'Cancela somente a execucao informada; o cron do projeto continua agendando novas execucoes. Use "zenifra project stop --project <id>" para pausar o projeto e interromper execucoes futuras.',
|
|
729
|
+
'O cancelamento aguarda ate 30 segundos pela finalizacao segura e pode forcar o encerramento depois desse periodo. Se a execucao nao puder ser cancelada com seguranca, aguarde que ela termine ou alcance o limite de tempo.',
|
|
730
|
+
],
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
command: 'project runs logs',
|
|
734
|
+
usage: 'zenifra project runs logs --project <id> --run <id> [--json]',
|
|
735
|
+
description: 'Mostra os logs de uma execucao especifica de um Job agendado.',
|
|
736
|
+
flags: ['--project <id> ID do projeto.', '--run <id> ID publico da execucao.', '--json Imprime a resposta em JSON.'],
|
|
737
|
+
examples: ['zenifra project runs logs --project 507f1f77bcf86cd799439012 --run run_1', 'zenifra project runs logs --project 507f1f77bcf86cd799439012 --run run_1 --json'],
|
|
738
|
+
output: 'started\ncompleted',
|
|
739
|
+
jsonOutput: '{"logs":"started\\ncompleted","truncated":false}',
|
|
740
|
+
notes: ['Os logs pertencem a uma execucao identificada pelo ID publico retornado em project runs.'],
|
|
741
|
+
},
|
|
701
742
|
{
|
|
702
743
|
command: 'project metrics',
|
|
703
744
|
usage: 'zenifra project metrics --project <id> [--instance <id>] [--json]',
|
|
@@ -1009,7 +1050,8 @@ function commandHelp(positionals) {
|
|
|
1009
1050
|
const key = helpKeyFromPositionals(positionals);
|
|
1010
1051
|
if (!key) return usage();
|
|
1011
1052
|
|
|
1012
|
-
const spec = HELP_BY_COMMAND.get(key)
|
|
1053
|
+
const spec = HELP_BY_COMMAND.get(key)
|
|
1054
|
+
|| (key === 'project run logs' ? HELP_BY_COMMAND.get('project runs logs') : undefined);
|
|
1013
1055
|
if (!spec) {
|
|
1014
1056
|
throw new CliError(`Ajuda nao encontrada para: ${key}. Use "zenifra help <command>".`);
|
|
1015
1057
|
}
|
|
@@ -1602,9 +1644,9 @@ async function promptWizardBoolean({ label, docs, examples = ['sim', 'nao'] }) {
|
|
|
1602
1644
|
}
|
|
1603
1645
|
}
|
|
1604
1646
|
|
|
1605
|
-
async function promptWizardSelect({ label, options, docs, examples = [] }) {
|
|
1647
|
+
async function promptWizardSelect({ label, options, docs, examples = [], extraValues = [] }) {
|
|
1606
1648
|
while (true) {
|
|
1607
|
-
const allowedValues = options.map((option) => option.value);
|
|
1649
|
+
const allowedValues = [...options.map((option) => option.value), ...extraValues];
|
|
1608
1650
|
const answer = await prompt(buildWizardPromptLine({
|
|
1609
1651
|
ui: promptWizardSelect.ui,
|
|
1610
1652
|
label,
|
|
@@ -1625,6 +1667,8 @@ async function promptWizardSelect({ label, options, docs, examples = [] }) {
|
|
|
1625
1667
|
const normalized = normalizeFreeText(answer);
|
|
1626
1668
|
const found = options.find((option) => normalizeFreeText(option.value) === normalized);
|
|
1627
1669
|
if (found) return found.value;
|
|
1670
|
+
const extraValue = extraValues.find((value) => normalizeFreeText(value) === normalized);
|
|
1671
|
+
if (extraValue) return extraValue;
|
|
1628
1672
|
process.stdout.write(`${tone(`valor invalido: escolha um item entre ${options.map((_, index) => index + 1).join(', ')} ou um dos valores aceitos.`, 'red')}\n`);
|
|
1629
1673
|
promptWizardSelect.ui.step -= 1;
|
|
1630
1674
|
}
|
|
@@ -1757,6 +1801,14 @@ function httpTimeoutMs() {
|
|
|
1757
1801
|
return value;
|
|
1758
1802
|
}
|
|
1759
1803
|
|
|
1804
|
+
function publicApiErrorMessage(value) {
|
|
1805
|
+
const message = String(value || 'Nao foi possivel concluir a operacao.');
|
|
1806
|
+
if (/kubernetes|k8s|namespace|cluster|pod|serviceaccount|deployment|internal[_ -]/i.test(message)) {
|
|
1807
|
+
return 'Nao foi possivel concluir a operacao. Tente novamente mais tarde.';
|
|
1808
|
+
}
|
|
1809
|
+
return message;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1760
1812
|
async function request(session, flags, method, path, {
|
|
1761
1813
|
body,
|
|
1762
1814
|
orgId,
|
|
@@ -1823,7 +1875,7 @@ async function request(session, flags, method, path, {
|
|
|
1823
1875
|
}
|
|
1824
1876
|
|
|
1825
1877
|
if (!response.ok) {
|
|
1826
|
-
const message = payload?.message || payload?.error || `Zenifra API retornou HTTP ${response.status}
|
|
1878
|
+
const message = publicApiErrorMessage(payload?.message || payload?.error || `Zenifra API retornou HTTP ${response.status}`);
|
|
1827
1879
|
const httpError = (errorMessage) => new CliError(errorMessage, 1, response.status);
|
|
1828
1880
|
if (response.status === 401) {
|
|
1829
1881
|
if (credential?.type === 'api_key') {
|
|
@@ -2440,9 +2492,66 @@ function printEnvs(envs, flags) {
|
|
|
2440
2492
|
]);
|
|
2441
2493
|
}
|
|
2442
2494
|
|
|
2495
|
+
function isJobProject(project) {
|
|
2496
|
+
return project?.type_project === 'job' || project?.config?.type_project === 'job';
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
const JOB_INFO_HIDDEN_KEYS = new Set(['domain', 'url', 'exposure', 'port', 'instances']);
|
|
2500
|
+
const JOB_CONFIG_HIDDEN_KEYS = new Set(['exposure', 'port', 'instances', 'network_access', 'domain', 'custom_domains', 'autoscaling', 'healthcheck']);
|
|
2501
|
+
const INTERNAL_PROJECT_KEYS = new Set([
|
|
2502
|
+
'current_instances',
|
|
2503
|
+
'runtime_name',
|
|
2504
|
+
'namespace',
|
|
2505
|
+
'k8s_job_name',
|
|
2506
|
+
]);
|
|
2507
|
+
|
|
2508
|
+
function sanitizePublicProjectValue(value) {
|
|
2509
|
+
if (Array.isArray(value)) return value.map(sanitizePublicProjectValue);
|
|
2510
|
+
if (!value || typeof value !== 'object') return value;
|
|
2511
|
+
return Object.fromEntries(Object.entries(value)
|
|
2512
|
+
.filter(([key]) => !INTERNAL_PROJECT_KEYS.has(key))
|
|
2513
|
+
.map(([key, entryValue]) => [key, sanitizePublicProjectValue(entryValue)]));
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
function sanitizeJobConfigInfo(value) {
|
|
2517
|
+
if (Array.isArray(value)) return value.map(sanitizeJobConfigInfo);
|
|
2518
|
+
if (!value || typeof value !== 'object') return value;
|
|
2519
|
+
return Object.fromEntries(Object.entries(value)
|
|
2520
|
+
.filter(([key]) => !JOB_CONFIG_HIDDEN_KEYS.has(key))
|
|
2521
|
+
.map(([key, entryValue]) => [key, sanitizeJobConfigInfo(entryValue)]));
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
function sanitizePublicProject(project) {
|
|
2525
|
+
const sanitized = sanitizePublicProjectValue(project);
|
|
2526
|
+
if (!sanitized || typeof sanitized !== 'object' || Array.isArray(sanitized)) return sanitized;
|
|
2527
|
+
return Object.fromEntries(Object.entries(sanitized)
|
|
2528
|
+
.filter(([key]) => !JOB_INFO_HIDDEN_KEYS.has(key))
|
|
2529
|
+
.map(([key, value]) => [key, key === 'config' ? sanitizeJobConfigInfo(value) : value]));
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
function sanitizeJobProjectResponse(payload) {
|
|
2533
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload;
|
|
2534
|
+
if (payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
|
|
2535
|
+
return { ...payload, data: sanitizePublicProject(payload.data) };
|
|
2536
|
+
}
|
|
2537
|
+
return sanitizePublicProjectValue(payload);
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
function publicProjectsData(data) {
|
|
2541
|
+
if (Array.isArray(data)) return data.map((project) => isJobProject(project) ? sanitizePublicProject(project) : project);
|
|
2542
|
+
if (!data || typeof data !== 'object') return data;
|
|
2543
|
+
return {
|
|
2544
|
+
...data,
|
|
2545
|
+
projects: Array.isArray(data.projects)
|
|
2546
|
+
? data.projects.map((project) => isJobProject(project) ? sanitizePublicProject(project) : project)
|
|
2547
|
+
: data.projects,
|
|
2548
|
+
};
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2443
2551
|
function printProject(project) {
|
|
2444
2552
|
const managedService = project.managed_service;
|
|
2445
2553
|
const isValkey = project.type_project === 'valkey' || managedService?.engine === 'valkey';
|
|
2554
|
+
const isJob = isJobProject(project);
|
|
2446
2555
|
const rows = [
|
|
2447
2556
|
{ label: 'ID', value: projectIdOf(project) || '-' },
|
|
2448
2557
|
{ label: 'Nome', value: project.name || '-' },
|
|
@@ -2451,7 +2560,13 @@ function printProject(project) {
|
|
|
2451
2560
|
{ label: 'Plano', value: project.plan || '-' },
|
|
2452
2561
|
];
|
|
2453
2562
|
|
|
2454
|
-
if (
|
|
2563
|
+
if (isJob) {
|
|
2564
|
+
const schedule = project.job || project.config?.job || {};
|
|
2565
|
+
rows.push(
|
|
2566
|
+
{ label: 'Agendamento', value: schedule.cron || '-' },
|
|
2567
|
+
{ label: 'Armazenamento', value: project.storage?.persistent === true || project.config?.storage?.persistent === true ? 'persistente' : 'efemero' },
|
|
2568
|
+
);
|
|
2569
|
+
} else if (isValkey) {
|
|
2455
2570
|
rows.push(
|
|
2456
2571
|
{ label: 'Perfil', value: managedService?.profile || '-' },
|
|
2457
2572
|
{ label: 'Versao', value: managedService?.version || '-' },
|
|
@@ -2918,9 +3033,10 @@ async function handleProjects(session, flags) {
|
|
|
2918
3033
|
};
|
|
2919
3034
|
const query = buildQuery(projectFlags, ['type', 'page', 'limit']);
|
|
2920
3035
|
const data = unwrapData(await request(session, flags, 'GET', `/project${query}`, { orgId }));
|
|
2921
|
-
const
|
|
3036
|
+
const publicData = publicProjectsData(data);
|
|
3037
|
+
const projects = asArray(publicData);
|
|
2922
3038
|
|
|
2923
|
-
if (flags.json) return printJson(
|
|
3039
|
+
if (flags.json) return printJson(publicData);
|
|
2924
3040
|
printTable(projects, [
|
|
2925
3041
|
{ label: 'ID', value: projectIdOf },
|
|
2926
3042
|
{ label: 'Nome', value: (project) => project.name || '-' },
|
|
@@ -3009,6 +3125,8 @@ function normalizePlan(value) {
|
|
|
3009
3125
|
]);
|
|
3010
3126
|
|
|
3011
3127
|
if (aliases.has(normalized)) return aliases.get(normalized);
|
|
3128
|
+
const normalizedJobPlan = normalized.startsWith('job_') ? `job-${normalized.slice(4)}` : '';
|
|
3129
|
+
if (WIZARD_JOB_PLAN_OPTIONS.includes(normalizedJobPlan)) return normalizedJobPlan;
|
|
3012
3130
|
if (ALLOWED_PLAN_VALUES.has(normalized)) return normalized;
|
|
3013
3131
|
return normalized;
|
|
3014
3132
|
}
|
|
@@ -3028,6 +3146,10 @@ function normalizePaymentMode(value) {
|
|
|
3028
3146
|
['annual', 'yearly'],
|
|
3029
3147
|
['anual', 'yearly'],
|
|
3030
3148
|
['por ano', 'yearly'],
|
|
3149
|
+
['per_minute', 'per_minute'],
|
|
3150
|
+
['per-minute', 'per_minute'],
|
|
3151
|
+
['por minuto', 'per_minute'],
|
|
3152
|
+
['minuto', 'per_minute'],
|
|
3031
3153
|
]);
|
|
3032
3154
|
return aliases.get(raw) || raw;
|
|
3033
3155
|
}
|
|
@@ -3164,12 +3286,21 @@ async function fetchWizardCatalogs(session, flags) {
|
|
|
3164
3286
|
return { httpPlans, availableInstances, databasePlans };
|
|
3165
3287
|
}
|
|
3166
3288
|
|
|
3289
|
+
function requireJobPlans(value) {
|
|
3290
|
+
const plans = Array.isArray(value) ? value : value?.plans;
|
|
3291
|
+
if (!Array.isArray(plans)) {
|
|
3292
|
+
throw new CliError('Falha ao carregar o catalogo de Jobs agendados. Tente novamente mais tarde.');
|
|
3293
|
+
}
|
|
3294
|
+
return plans;
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3167
3297
|
function normalizePlansCatalogType(value) {
|
|
3168
3298
|
const normalized = normalizeFreeText(value || 'all');
|
|
3169
3299
|
if (!normalized || normalized === 'all') return 'all';
|
|
3170
3300
|
if (normalized === 'http') return 'http';
|
|
3171
3301
|
if (['database', 'db', 'postgresql', 'mariadb'].includes(normalized)) return 'database';
|
|
3172
3302
|
if (normalized === 'valkey') return 'valkey';
|
|
3303
|
+
if (normalized === 'job' || normalized === 'jobs' || normalized === 'scheduled job' || normalized === 'scheduled jobs') return 'job';
|
|
3173
3304
|
if (normalized === 'key-value' || normalized === 'key value' || normalized === 'key_value') return 'valkey-key_value';
|
|
3174
3305
|
if (normalized === 'cache') return 'valkey-cache';
|
|
3175
3306
|
if (normalized === 'queue') return 'valkey-queue';
|
|
@@ -3190,6 +3321,18 @@ function formatBrlFromCents(value) {
|
|
|
3190
3321
|
return formatBrl(Number.isFinite(number) ? number / 100 : 0);
|
|
3191
3322
|
}
|
|
3192
3323
|
|
|
3324
|
+
function formatJobBrlFromCents(value) {
|
|
3325
|
+
if (value === undefined || value === null || value === '') return '-';
|
|
3326
|
+
const number = Number(value);
|
|
3327
|
+
if (!Number.isFinite(number)) return '-';
|
|
3328
|
+
return new Intl.NumberFormat('pt-BR', {
|
|
3329
|
+
style: 'currency',
|
|
3330
|
+
currency: 'BRL',
|
|
3331
|
+
minimumFractionDigits: 2,
|
|
3332
|
+
maximumFractionDigits: 4,
|
|
3333
|
+
}).format(number / 100);
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3193
3336
|
function humanizeStorageName(value) {
|
|
3194
3337
|
const raw = String(value || '').trim();
|
|
3195
3338
|
if (!raw) return { storage: '-', type: '-' };
|
|
@@ -3287,6 +3430,17 @@ async function fetchPlansCatalogs(session, flags, type) {
|
|
|
3287
3430
|
};
|
|
3288
3431
|
}
|
|
3289
3432
|
|
|
3433
|
+
if (type === 'job') {
|
|
3434
|
+
return {
|
|
3435
|
+
http: [],
|
|
3436
|
+
database: [],
|
|
3437
|
+
storage: [],
|
|
3438
|
+
job: requireJobPlans(
|
|
3439
|
+
unwrapData(await request(session, flags, 'GET', '/project/job/plans', { tokenRequired: false })),
|
|
3440
|
+
),
|
|
3441
|
+
};
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3290
3444
|
if (type === 'valkey' || type.startsWith('valkey-')) {
|
|
3291
3445
|
const catalog = requireValkeyCatalog(
|
|
3292
3446
|
unwrapData(await request(session, flags, 'GET', '/managed-services/catalog', { tokenRequired: false })),
|
|
@@ -3299,10 +3453,11 @@ async function fetchPlansCatalogs(session, flags, type) {
|
|
|
3299
3453
|
};
|
|
3300
3454
|
}
|
|
3301
3455
|
|
|
3302
|
-
const [httpPayload, databasePayload, storagePayload, valkeyPayload] = await Promise.all([
|
|
3456
|
+
const [httpPayload, databasePayload, storagePayload, jobPayload, valkeyPayload] = await Promise.all([
|
|
3303
3457
|
request(session, flags, 'GET', '/project/plans', { tokenRequired: false }),
|
|
3304
3458
|
request(session, flags, 'GET', '/project/database/plans', { tokenRequired: false }),
|
|
3305
3459
|
request(session, flags, 'GET', '/project/storage/plans', { tokenRequired: false }),
|
|
3460
|
+
request(session, flags, 'GET', '/project/job/plans', { tokenRequired: false }),
|
|
3306
3461
|
request(session, flags, 'GET', '/managed-services/catalog', { tokenRequired: false }),
|
|
3307
3462
|
]);
|
|
3308
3463
|
|
|
@@ -3310,6 +3465,7 @@ async function fetchPlansCatalogs(session, flags, type) {
|
|
|
3310
3465
|
http: requireWizardCatalogArray(unwrapData(httpPayload), 'os planos HTTP'),
|
|
3311
3466
|
database: requireWizardCatalogArray(unwrapData(databasePayload), 'os planos de banco'),
|
|
3312
3467
|
storage: requireWizardCatalogArray(unwrapData(storagePayload), 'os planos de storage'),
|
|
3468
|
+
job: requireJobPlans(unwrapData(jobPayload)),
|
|
3313
3469
|
valkey: requireValkeyCatalog(unwrapData(valkeyPayload)),
|
|
3314
3470
|
};
|
|
3315
3471
|
}
|
|
@@ -3348,6 +3504,15 @@ function formatPlanCapabilities(capabilities) {
|
|
|
3348
3504
|
return available.join(', ') || '-';
|
|
3349
3505
|
}
|
|
3350
3506
|
|
|
3507
|
+
function printJobCatalog(plans) {
|
|
3508
|
+
process.stdout.write('Jobs agendados' + String.fromCharCode(10));
|
|
3509
|
+
printTable(asArray(plans), [
|
|
3510
|
+
{ label: 'Plano', value: (plan) => plan.plan || plan.id || '-' },
|
|
3511
|
+
{ label: 'Por minuto', value: (plan) => formatJobBrlFromCents(plan.price_per_minute) },
|
|
3512
|
+
{ label: 'Features', value: (plan) => asArray(plan.features).join(', ') || '-' },
|
|
3513
|
+
]);
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3351
3516
|
function printPlansCatalogs(payload, type) {
|
|
3352
3517
|
if (type === 'all' || type === 'http') {
|
|
3353
3518
|
process.stdout.write('HTTP\n');
|
|
@@ -3385,6 +3550,16 @@ function printPlansCatalogs(payload, type) {
|
|
|
3385
3550
|
]);
|
|
3386
3551
|
}
|
|
3387
3552
|
|
|
3553
|
+
if (type === 'all') {
|
|
3554
|
+
process.stdout.write('\n');
|
|
3555
|
+
printJobCatalog(payload.job);
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
if (type === 'job') {
|
|
3559
|
+
printJobCatalog(payload.job);
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3388
3563
|
if (type === 'all' || type === 'valkey' || type.startsWith('valkey-')) {
|
|
3389
3564
|
if (type === 'all') process.stdout.write('\n');
|
|
3390
3565
|
printValkeyCatalog(payload.valkey, valkeyProfileFilter(type));
|
|
@@ -3394,7 +3569,7 @@ function printPlansCatalogs(payload, type) {
|
|
|
3394
3569
|
async function handlePlans(session, flags) {
|
|
3395
3570
|
const type = normalizePlansCatalogType(flags.type);
|
|
3396
3571
|
if (!type) {
|
|
3397
|
-
throw new CliError('Tipo de catalogo invalido. Valores aceitos: all, http, database, storage, valkey.');
|
|
3572
|
+
throw new CliError('Tipo de catalogo invalido. Valores aceitos: all, http, database, storage, valkey, job, key-value, cache ou queue.');
|
|
3398
3573
|
}
|
|
3399
3574
|
|
|
3400
3575
|
const payload = await fetchPlansCatalogs(session, flags, type);
|
|
@@ -3406,6 +3581,75 @@ function formatAllowedValues(values) {
|
|
|
3406
3581
|
return [...values].join(', ');
|
|
3407
3582
|
}
|
|
3408
3583
|
|
|
3584
|
+
function validateJobConfig(config) {
|
|
3585
|
+
if (!isRecord(config)) throw new CliError('config deve ser um objeto.');
|
|
3586
|
+
|
|
3587
|
+
if (!isRecord(config.job)) throw new CliError('Jobs exigem config.job.');
|
|
3588
|
+
const cron = String(config.job.cron || '').trim();
|
|
3589
|
+
if (cron.split(/\s+/).filter(Boolean).length !== 5) {
|
|
3590
|
+
throw new CliError('config.job.cron deve conter exatamente cinco campos em UTC.');
|
|
3591
|
+
}
|
|
3592
|
+
const forbiddenFields = ['instances', 'port', 'exposure', 'network_access', 'domain', 'custom_domains', 'autoscaling', 'healthcheck'];
|
|
3593
|
+
const forbiddenField = forbiddenFields.find((field) => config[field] !== undefined);
|
|
3594
|
+
if (forbiddenField) {
|
|
3595
|
+
throw new CliError(`Jobs nao aceitam config.${forbiddenField}.`);
|
|
3596
|
+
}
|
|
3597
|
+
const forbiddenJobField = ['command', 'args'].find((field) => config.job[field] !== undefined);
|
|
3598
|
+
if (forbiddenJobField) {
|
|
3599
|
+
throw new CliError(`Jobs nao aceitam config.job.${forbiddenJobField}. A imagem ja define o comando e os argumentos.`);
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
if (!isRecord(config.image) || typeof config.image.url !== 'string' || !config.image.url.trim()) {
|
|
3603
|
+
throw new CliError('Jobs exigem uma imagem OCI em config.image. O fluxo GitHub ainda nao esta disponivel para Jobs.');
|
|
3604
|
+
}
|
|
3605
|
+
const imageReference = config.image.url.trim();
|
|
3606
|
+
if (imageReference.length < OCI_IMAGE_REFERENCE_MIN_LENGTH
|
|
3607
|
+
|| imageReference.length > OCI_IMAGE_REFERENCE_MAX_LENGTH
|
|
3608
|
+
|| !OCI_IMAGE_REFERENCE_PATTERN.test(imageReference)) {
|
|
3609
|
+
throw new CliError('config.image.url deve ser uma referencia OCI completa com registro e tag ou digest.');
|
|
3610
|
+
}
|
|
3611
|
+
if (config.github !== undefined) {
|
|
3612
|
+
throw new CliError('Jobs aceitam somente config.image. O fluxo GitHub ainda nao esta disponivel para Jobs.');
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
if (!Array.isArray(config.envs)) {
|
|
3616
|
+
throw new CliError('config.envs deve ser um array.');
|
|
3617
|
+
}
|
|
3618
|
+
if (config.envs.length > 100) throw new CliError('config.envs nao pode conter mais de 100 variaveis.');
|
|
3619
|
+
for (const env of config.envs) {
|
|
3620
|
+
if (!isRecord(env) || typeof env.name !== 'string' || typeof env.value !== 'string') {
|
|
3621
|
+
throw new CliError('Cada item de config.envs deve conter name e value como texto.');
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3625
|
+
if (!isRecord(config.storage)) throw new CliError('Jobs exigem config.storage.');
|
|
3626
|
+
if (typeof config.storage.persistent !== 'boolean') {
|
|
3627
|
+
throw new CliError('config.storage.persistent deve ser booleano.');
|
|
3628
|
+
}
|
|
3629
|
+
const capacity = Number(config.storage.capacity);
|
|
3630
|
+
if (!Number.isInteger(capacity) || capacity < 1 || capacity > 250) {
|
|
3631
|
+
throw new CliError('config.storage.capacity deve ser um inteiro entre 1 e 250 GiB.');
|
|
3632
|
+
}
|
|
3633
|
+
if (config.storage.persistent) {
|
|
3634
|
+
if (typeof config.storage.dir_path_to_persist !== 'string' || !config.storage.dir_path_to_persist.trim()) {
|
|
3635
|
+
throw new CliError('config.storage.dir_path_to_persist e obrigatorio para storage persistente.');
|
|
3636
|
+
}
|
|
3637
|
+
} else if (config.storage.dir_path_to_persist !== undefined) {
|
|
3638
|
+
throw new CliError('config.storage.dir_path_to_persist nao e aceito para storage efemero.');
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
return { ...config, type_project: 'job', job: { ...config.job, cron } };
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
function validateJobPlanAndPayment(plan, paymentMode) {
|
|
3645
|
+
if (!WIZARD_JOB_PLAN_OPTIONS.includes(plan)) {
|
|
3646
|
+
throw new CliError(`Plano invalido para Jobs: "${plan}". Valores aceitos: ${formatAllowedValues(WIZARD_JOB_PLAN_OPTIONS)}.`);
|
|
3647
|
+
}
|
|
3648
|
+
if (paymentMode !== 'per_minute') {
|
|
3649
|
+
throw new CliError('Jobs agendados aceitam somente o modo de pagamento per_minute.');
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3409
3653
|
function validateCreateAutoscaling(config, plan, typeProject) {
|
|
3410
3654
|
if (config?.autoscaling === undefined) return config;
|
|
3411
3655
|
if (typeProject !== 'http') {
|
|
@@ -3549,13 +3793,22 @@ function validateGithubVersionDeployConfig(github) {
|
|
|
3549
3793
|
function validateCreateInput({ plan, paymentMode, config, valkeyCatalog }) {
|
|
3550
3794
|
const nextTypeProject = normalizeTypeProject(config?.type_project);
|
|
3551
3795
|
const nextPlan = normalizePlan(plan);
|
|
3796
|
+
const nextPaymentMode = normalizePaymentMode(paymentMode);
|
|
3797
|
+
|
|
3798
|
+
if (nextTypeProject === 'job') {
|
|
3799
|
+
validateJobPlanAndPayment(nextPlan, nextPaymentMode);
|
|
3800
|
+
return {
|
|
3801
|
+
plan: nextPlan,
|
|
3802
|
+
paymentMode: nextPaymentMode,
|
|
3803
|
+
config: validateJobConfig({ ...config, type_project: nextTypeProject }),
|
|
3804
|
+
};
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3552
3807
|
if (!nextPlan || (nextTypeProject !== 'valkey' && !ALLOWED_PLAN_VALUES.has(nextPlan))) {
|
|
3553
3808
|
throw new CliError(`Plano invalido: "${plan}". Valores aceitos: ${formatAllowedValues(ALLOWED_PLAN_VALUES)}. Docs: ${DOCS_CREATE_HTTP_URL}`);
|
|
3554
3809
|
}
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
if (!nextPaymentMode || !ALLOWED_PAYMENT_MODE_VALUES.has(nextPaymentMode)) {
|
|
3558
|
-
throw new CliError(`Modo de pagamento invalido: "${paymentMode}". Valores aceitos: ${formatAllowedValues(ALLOWED_PAYMENT_MODE_VALUES)}. Docs: ${DOCS_PAYMENTS_URL}`);
|
|
3810
|
+
if (!nextPaymentMode || !ALLOWED_PAYMENT_MODE_VALUES.has(nextPaymentMode) || nextPaymentMode === 'per_minute') {
|
|
3811
|
+
throw new CliError(`Modo de pagamento invalido: "${paymentMode}". Valores aceitos: ${formatAllowedValues(new Set(['hourly', 'monthly', 'yearly']))}. Docs: ${DOCS_PAYMENTS_URL}`);
|
|
3559
3812
|
}
|
|
3560
3813
|
|
|
3561
3814
|
if (!nextTypeProject || !ALLOWED_TYPE_PROJECT_VALUES.has(nextTypeProject)) {
|
|
@@ -3624,10 +3877,13 @@ function maskWizardSummary(value, key = '') {
|
|
|
3624
3877
|
|
|
3625
3878
|
function printWizardSummary(payload) {
|
|
3626
3879
|
const masked = maskWizardSummary(payload);
|
|
3880
|
+
const projectSummary = masked.config.type_project === 'job'
|
|
3881
|
+
? ` projeto: ${masked.name} | plano: ${masked.plan}`
|
|
3882
|
+
: ` projeto: ${masked.name} | plano: ${masked.plan} | pagamento: ${masked.payment_mode}`;
|
|
3627
3883
|
const lines = [
|
|
3628
3884
|
'',
|
|
3629
3885
|
tone('Resumo da criacao', 'green'),
|
|
3630
|
-
|
|
3886
|
+
projectSummary,
|
|
3631
3887
|
` tipo: ${masked.config.type_project}`,
|
|
3632
3888
|
];
|
|
3633
3889
|
|
|
@@ -3649,6 +3905,12 @@ function printWizardSummary(payload) {
|
|
|
3649
3905
|
lines.push(` banco: versao ${masked.config.version} | instancias ${masked.config.instances} | storage ${masked.config.storage.capacity}Gi`);
|
|
3650
3906
|
}
|
|
3651
3907
|
|
|
3908
|
+
if (masked.config.type_project === 'job') {
|
|
3909
|
+
lines.push(` Job: cron ${masked.config.job.cron} UTC | storage ${masked.config.storage.capacity}Gi (${masked.config.storage.persistent ? 'persistente' : 'efemero'})`);
|
|
3910
|
+
if (masked.config.image) lines.push(` imagem: ${masked.config.image.url} | publica: ${masked.config.image.is_public ? 'sim' : 'nao'}`);
|
|
3911
|
+
if (masked.config.github) lines.push(` github: ${masked.config.github.repository_owner}/${masked.config.github.repository_name}@${masked.config.github.branch}`);
|
|
3912
|
+
}
|
|
3913
|
+
|
|
3652
3914
|
if (masked.config.type_project === 'valkey') {
|
|
3653
3915
|
lines.push(` Valkey: perfil ${masked.config.profile} | versao ${masked.config.version}${masked.config.storage ? ` | storage ${masked.config.storage.capacity}Gi persistente` : ' | sem storage persistente'}`);
|
|
3654
3916
|
}
|
|
@@ -4138,6 +4400,67 @@ async function buildValkeyConfig({ profile, plan, catalog }) {
|
|
|
4138
4400
|
};
|
|
4139
4401
|
}
|
|
4140
4402
|
|
|
4403
|
+
async function buildJobConfig() {
|
|
4404
|
+
const image = {
|
|
4405
|
+
url: await promptWizardText({
|
|
4406
|
+
label: 'Imagem OCI da tarefa',
|
|
4407
|
+
required: true,
|
|
4408
|
+
examples: ['ghcr.io/example/report:1.0.0', 'registry.example.com/team/job@sha256:digest'],
|
|
4409
|
+
docs: DOCS_CONFIGURATION_URL,
|
|
4410
|
+
validate: (value) => {
|
|
4411
|
+
const reference = value.trim();
|
|
4412
|
+
return reference.length >= OCI_IMAGE_REFERENCE_MIN_LENGTH
|
|
4413
|
+
&& reference.length <= OCI_IMAGE_REFERENCE_MAX_LENGTH
|
|
4414
|
+
&& OCI_IMAGE_REFERENCE_PATTERN.test(reference)
|
|
4415
|
+
? null
|
|
4416
|
+
: 'use uma referencia OCI com registro e tag ou digest';
|
|
4417
|
+
},
|
|
4418
|
+
}),
|
|
4419
|
+
is_public: true,
|
|
4420
|
+
};
|
|
4421
|
+
const persistent = await promptWizardBoolean({
|
|
4422
|
+
label: 'Armazenamento persistente',
|
|
4423
|
+
docs: DOCS_CONFIGURATION_URL,
|
|
4424
|
+
examples: ['sim', 'nao'],
|
|
4425
|
+
});
|
|
4426
|
+
const storage = {
|
|
4427
|
+
persistent,
|
|
4428
|
+
capacity: await promptWizardNumber({
|
|
4429
|
+
label: 'Capacidade de armazenamento (Gi)',
|
|
4430
|
+
docs: DOCS_CONFIGURATION_URL,
|
|
4431
|
+
examples: ['1', '5', '10'],
|
|
4432
|
+
min: 1,
|
|
4433
|
+
max: 250,
|
|
4434
|
+
}),
|
|
4435
|
+
...(persistent ? {
|
|
4436
|
+
dir_path_to_persist: await promptWizardText({
|
|
4437
|
+
label: 'Diretorio persistente',
|
|
4438
|
+
required: true,
|
|
4439
|
+
examples: ['/data'],
|
|
4440
|
+
docs: DOCS_CONFIGURATION_URL,
|
|
4441
|
+
}),
|
|
4442
|
+
} : {}),
|
|
4443
|
+
};
|
|
4444
|
+
const envs = await promptWizardPairs({
|
|
4445
|
+
introLabel: 'Deseja adicionar variaveis de ambiente?',
|
|
4446
|
+
docs: DOCS_CONFIGURATION_URL,
|
|
4447
|
+
examples: ['sim', 'nao'],
|
|
4448
|
+
stateKey: 'jobEnvs',
|
|
4449
|
+
itemLabels: [
|
|
4450
|
+
{ key: 'name', label: 'Nome da env', required: true, examples: ['MODE'], docs: DOCS_CONFIGURATION_URL },
|
|
4451
|
+
{ key: 'value', label: 'Valor da env', required: true, examples: ['daily'], docs: DOCS_CONFIGURATION_URL },
|
|
4452
|
+
],
|
|
4453
|
+
});
|
|
4454
|
+
const cron = await promptWizardText({
|
|
4455
|
+
label: 'Cron em UTC (cinco campos)',
|
|
4456
|
+
required: true,
|
|
4457
|
+
examples: ['0 * * * *'],
|
|
4458
|
+
docs: DOCS_CONFIGURATION_URL,
|
|
4459
|
+
validate: (value) => value.split(/\s+/).filter(Boolean).length === 5 ? null : 'use exatamente cinco campos em UTC',
|
|
4460
|
+
});
|
|
4461
|
+
return { type_project: 'job', image, envs, storage, job: { cron } };
|
|
4462
|
+
}
|
|
4463
|
+
|
|
4141
4464
|
async function runProjectCreateWizard(session, flags) {
|
|
4142
4465
|
const catalogs = await fetchWizardCatalogs(session, flags);
|
|
4143
4466
|
const ui = createWizardUi();
|
|
@@ -4166,11 +4489,15 @@ async function runProjectCreateWizard(session, flags) {
|
|
|
4166
4489
|
docs: DOCS_CONFIGURATION_URL,
|
|
4167
4490
|
examples: WIZARD_TYPE_PROJECT_OPTIONS,
|
|
4168
4491
|
options: WIZARD_TYPE_PROJECT_OPTIONS.map((value) => ({ value })),
|
|
4492
|
+
extraValues: ['job'],
|
|
4169
4493
|
});
|
|
4170
4494
|
ui.state.typeProject = typeProject;
|
|
4171
4495
|
const valkeyCatalog = typeProject === 'valkey'
|
|
4172
4496
|
? requireValkeyCatalog(unwrapData(await request(session, flags, 'GET', '/managed-services/catalog')))
|
|
4173
4497
|
: undefined;
|
|
4498
|
+
const jobPlans = typeProject === 'job'
|
|
4499
|
+
? requireJobPlans(unwrapData(await request(session, flags, 'GET', '/project/job/plans')))
|
|
4500
|
+
: undefined;
|
|
4174
4501
|
const profile = typeProject === 'valkey'
|
|
4175
4502
|
? await promptWizardSelect({
|
|
4176
4503
|
label: 'Perfil Valkey',
|
|
@@ -4188,6 +4515,8 @@ async function runProjectCreateWizard(session, flags) {
|
|
|
4188
4515
|
? valkeyCatalog.plans
|
|
4189
4516
|
.filter((planDefinition) => planDefinition.profile === profile)
|
|
4190
4517
|
.map((planDefinition) => planDefinition.id)
|
|
4518
|
+
: typeProject === 'job'
|
|
4519
|
+
? sortWizardOptions(jobPlans.map((plan) => plan?.plan || plan?.id).filter(Boolean), WIZARD_JOB_PLAN_OPTIONS)
|
|
4191
4520
|
: sortWizardOptions(catalogs.databasePlans
|
|
4192
4521
|
.map((plan) => plan?.plan)
|
|
4193
4522
|
.filter((plan) => WIZARD_DATABASE_PLAN_OPTIONS.includes(plan) && (typeProject !== 'mariadb' || plan !== 'db-free')), WIZARD_DATABASE_PLAN_OPTIONS);
|
|
@@ -4195,7 +4524,8 @@ async function runProjectCreateWizard(session, flags) {
|
|
|
4195
4524
|
label: 'Plano',
|
|
4196
4525
|
docs: typeProject === 'http'
|
|
4197
4526
|
? DOCS_CREATE_HTTP_URL
|
|
4198
|
-
: typeProject === 'valkey' ? DOCS_CREATE_VALKEY_URL
|
|
4527
|
+
: typeProject === 'valkey' ? DOCS_CREATE_VALKEY_URL
|
|
4528
|
+
: typeProject === 'job' ? DOCS_CONFIGURATION_URL : DOCS_DATABASE_CONFIGURATION_URL,
|
|
4199
4529
|
examples: [planOptions[0], planOptions.at(-1)],
|
|
4200
4530
|
options: planOptions.map((value) => ({ value })),
|
|
4201
4531
|
});
|
|
@@ -4205,7 +4535,7 @@ async function runProjectCreateWizard(session, flags) {
|
|
|
4205
4535
|
ui.state.planAllowsSubdomain = isSubdomainCustomizable(catalogs.httpPlans, plan);
|
|
4206
4536
|
ui.state.planAllowsAutoscaling = isAutoscalingEnabledForPlan(catalogs.httpPlans, plan);
|
|
4207
4537
|
}
|
|
4208
|
-
const paymentMode = await promptWizardSelect({
|
|
4538
|
+
const paymentMode = typeProject === 'job' ? 'per_minute' : await promptWizardSelect({
|
|
4209
4539
|
label: 'Modo de pagamento',
|
|
4210
4540
|
docs: DOCS_PAYMENTS_URL,
|
|
4211
4541
|
examples: WIZARD_PAYMENT_MODE_OPTIONS,
|
|
@@ -4223,6 +4553,8 @@ async function runProjectCreateWizard(session, flags) {
|
|
|
4223
4553
|
config = await buildPostgresqlConfig({ plan, databasePlans: catalogs.databasePlans });
|
|
4224
4554
|
} else if (typeProject === 'valkey') {
|
|
4225
4555
|
config = await buildValkeyConfig({ profile, plan, catalog: valkeyCatalog });
|
|
4556
|
+
} else if (typeProject === 'job') {
|
|
4557
|
+
config = await buildJobConfig();
|
|
4226
4558
|
} else {
|
|
4227
4559
|
config = await buildMariadbConfig();
|
|
4228
4560
|
}
|
|
@@ -4259,13 +4591,15 @@ async function handleProjectCreate(session, flags) {
|
|
|
4259
4591
|
const name = wizardPayload?.name || flags.name || await prompt('Nome do projeto');
|
|
4260
4592
|
const description = wizardPayload?.description || flags.description;
|
|
4261
4593
|
const plan = wizardPayload?.plan || flags.plan || await prompt('Plano');
|
|
4262
|
-
const paymentMode = wizardPayload?.payment_mode || flags.paymentMode || await prompt('Modo de pagamento');
|
|
4263
4594
|
const config = wizardPayload?.config || await parseConfig(flags.config || await prompt('Config JSON ou @arquivo'));
|
|
4264
4595
|
const idempotencyKey = flags.idempotencyKey === undefined ? undefined : validateIdempotencyKey(flags.idempotencyKey);
|
|
4265
4596
|
if (Array.isArray(config?.custom_domains)) {
|
|
4266
4597
|
config.custom_domains = normalizeCustomDomains(config.custom_domains, { primaryDomain: config.domain });
|
|
4267
4598
|
}
|
|
4268
4599
|
const typeProject = normalizeTypeProject(config?.type_project);
|
|
4600
|
+
const paymentMode = typeProject === 'job'
|
|
4601
|
+
? flags.paymentMode || 'per_minute'
|
|
4602
|
+
: wizardPayload?.payment_mode || flags.paymentMode || await prompt('Modo de pagamento');
|
|
4269
4603
|
const valkeyCatalog = typeProject === 'valkey'
|
|
4270
4604
|
? requireValkeyCatalog(unwrapData(await request(session, flags, 'GET', '/managed-services/catalog')))
|
|
4271
4605
|
: undefined;
|
|
@@ -4289,14 +4623,16 @@ async function handleProjectCreate(session, flags) {
|
|
|
4289
4623
|
},
|
|
4290
4624
|
});
|
|
4291
4625
|
|
|
4292
|
-
if (flags.json)
|
|
4626
|
+
if (flags.json) {
|
|
4627
|
+
return printJson(validated.config.type_project === 'job' ? sanitizeJobProjectResponse(payload) : payload);
|
|
4628
|
+
}
|
|
4293
4629
|
if (wizardPayload) {
|
|
4294
4630
|
closePromptResources();
|
|
4295
4631
|
}
|
|
4296
4632
|
const project = unwrapData(payload);
|
|
4297
4633
|
const rows = [
|
|
4298
4634
|
{ field: 'Projeto', value: projectIdOf(project) || 'id indisponivel' },
|
|
4299
|
-
...(project.domain ? [{ field: 'Dominio', value: formatPublicUrl(project.domain) }] : []),
|
|
4635
|
+
...(validated.config.type_project === 'job' ? [] : project.domain ? [{ field: 'Dominio', value: formatPublicUrl(project.domain) }] : []),
|
|
4300
4636
|
...(project.api_key ? [{ field: 'API key', value: project.api_key }] : []),
|
|
4301
4637
|
];
|
|
4302
4638
|
if (validated.config.type_project === 'valkey') {
|
|
@@ -4331,7 +4667,9 @@ async function handleProjectInfo(session, flags) {
|
|
|
4331
4667
|
const orgId = await resolveOrgId(session, flags);
|
|
4332
4668
|
const project = await getProject(session, flags, projectId, orgId);
|
|
4333
4669
|
|
|
4334
|
-
if (flags.json) return printJson(projectInfoForOutput(
|
|
4670
|
+
if (flags.json) return printJson(projectInfoForOutput(
|
|
4671
|
+
isJobProject(project) ? sanitizePublicProject(project) : project,
|
|
4672
|
+
));
|
|
4335
4673
|
printProject({ ...project, id: projectId });
|
|
4336
4674
|
}
|
|
4337
4675
|
|
|
@@ -4544,6 +4882,231 @@ async function handleProjectLogs(session, flags) {
|
|
|
4544
4882
|
printLogs(logs, flags);
|
|
4545
4883
|
}
|
|
4546
4884
|
|
|
4885
|
+
const PUBLIC_JOB_RUN_KEYS = [
|
|
4886
|
+
'status',
|
|
4887
|
+
'scheduled_at',
|
|
4888
|
+
'started_at',
|
|
4889
|
+
'finished_at',
|
|
4890
|
+
'duration_seconds',
|
|
4891
|
+
'billed_minutes',
|
|
4892
|
+
'plan',
|
|
4893
|
+
'currency',
|
|
4894
|
+
'amount',
|
|
4895
|
+
];
|
|
4896
|
+
|
|
4897
|
+
function sanitizeJobRun(run) {
|
|
4898
|
+
if (!run || typeof run !== 'object' || Array.isArray(run)) return {};
|
|
4899
|
+
const output = {};
|
|
4900
|
+
const id = run.id || run._id || run.run_id;
|
|
4901
|
+
if (id !== undefined) output.id = id;
|
|
4902
|
+
for (const key of PUBLIC_JOB_RUN_KEYS) {
|
|
4903
|
+
if (run[key] !== undefined) output[key] = run[key];
|
|
4904
|
+
}
|
|
4905
|
+
return output;
|
|
4906
|
+
}
|
|
4907
|
+
|
|
4908
|
+
function jobRunsDataOf(data) {
|
|
4909
|
+
if (Array.isArray(data)) return { runs: data.map(sanitizeJobRun), pagination: null };
|
|
4910
|
+
if (!data || typeof data !== 'object') return { runs: [], pagination: null };
|
|
4911
|
+
return {
|
|
4912
|
+
runs: Array.isArray(data.runs) ? data.runs.map(sanitizeJobRun) : [],
|
|
4913
|
+
pagination: data.pagination ?? null,
|
|
4914
|
+
};
|
|
4915
|
+
}
|
|
4916
|
+
|
|
4917
|
+
function sanitizeJobRunResponse(data) {
|
|
4918
|
+
if (data && typeof data === 'object' && !Array.isArray(data) && data.run !== undefined) {
|
|
4919
|
+
return { run: sanitizeJobRun(data.run) };
|
|
4920
|
+
}
|
|
4921
|
+
return sanitizeJobRun(data);
|
|
4922
|
+
}
|
|
4923
|
+
|
|
4924
|
+
function formatJobRunDuration(durationSeconds) {
|
|
4925
|
+
if (durationSeconds === undefined || durationSeconds === null || !Number.isFinite(Number(durationSeconds))) return '-';
|
|
4926
|
+
return `${Number(Number(durationSeconds).toFixed(1))} s`;
|
|
4927
|
+
}
|
|
4928
|
+
|
|
4929
|
+
function formatJobRunAmount(run) {
|
|
4930
|
+
const amount = run.amount;
|
|
4931
|
+
if (amount === undefined || amount === null) return '-';
|
|
4932
|
+
return formatJobBrlFromCents(amount);
|
|
4933
|
+
}
|
|
4934
|
+
|
|
4935
|
+
function printJobRuns(data) {
|
|
4936
|
+
const runs = asArray(data?.runs);
|
|
4937
|
+
printTable(runs, [
|
|
4938
|
+
{ label: 'ID', value: (run) => run.id || '-' },
|
|
4939
|
+
{ label: 'Status', value: (run) => run.status || '-' },
|
|
4940
|
+
{ label: 'Agendada', value: (run) => run.scheduled_at || '-' },
|
|
4941
|
+
{ label: 'Iniciada', value: (run) => run.started_at || '-' },
|
|
4942
|
+
{ label: 'Finalizada', value: (run) => run.finished_at || '-' },
|
|
4943
|
+
{ label: 'Duracao', value: (run) => formatJobRunDuration(run.duration_seconds) },
|
|
4944
|
+
{ label: 'Minutos cobrados', value: (run) => run.billed_minutes ?? '-' },
|
|
4945
|
+
{ label: 'Valor', value: formatJobRunAmount },
|
|
4946
|
+
]);
|
|
4947
|
+
printPagination(data?.pagination, 'execucao');
|
|
4948
|
+
}
|
|
4949
|
+
|
|
4950
|
+
function sanitizeJobRunLogEntries(logs) {
|
|
4951
|
+
if (!Array.isArray(logs)) return logs;
|
|
4952
|
+
return logs.map((entry) => {
|
|
4953
|
+
if (typeof entry === 'string') return entry;
|
|
4954
|
+
if (!entry || typeof entry !== 'object') return String(entry ?? '');
|
|
4955
|
+
return Object.fromEntries(['sequence', 'timestamp', 'level', 'message']
|
|
4956
|
+
.filter((key) => entry[key] !== undefined)
|
|
4957
|
+
.map((key) => [key, entry[key]]));
|
|
4958
|
+
});
|
|
4959
|
+
}
|
|
4960
|
+
|
|
4961
|
+
function sanitizeJobRunLogs(data) {
|
|
4962
|
+
if (typeof data === 'string') return data;
|
|
4963
|
+
if (Array.isArray(data)) return sanitizeJobRunLogEntries(data);
|
|
4964
|
+
if (!data || typeof data !== 'object') return data;
|
|
4965
|
+
const output = {};
|
|
4966
|
+
for (const key of ['logs', 'next_cursor', 'truncated', 'finished', 'status']) {
|
|
4967
|
+
if (data[key] !== undefined) output[key] = key === 'logs' ? sanitizeJobRunLogEntries(data[key]) : data[key];
|
|
4968
|
+
}
|
|
4969
|
+
return output;
|
|
4970
|
+
}
|
|
4971
|
+
|
|
4972
|
+
function skipTerminalString(text, index) {
|
|
4973
|
+
while (index < text.length) {
|
|
4974
|
+
const code = text.charCodeAt(index);
|
|
4975
|
+
if (code === 0x07 || code === 0x9c) return index + 1;
|
|
4976
|
+
if (code === 0x1b && text.charCodeAt(index + 1) === 0x5c) return index + 2;
|
|
4977
|
+
index += 1;
|
|
4978
|
+
}
|
|
4979
|
+
return index;
|
|
4980
|
+
}
|
|
4981
|
+
|
|
4982
|
+
function skipTerminalCsi(text, index) {
|
|
4983
|
+
while (index < text.length) {
|
|
4984
|
+
const code = text.charCodeAt(index);
|
|
4985
|
+
index += 1;
|
|
4986
|
+
if (code >= 0x40 && code <= 0x7e) break;
|
|
4987
|
+
}
|
|
4988
|
+
return index;
|
|
4989
|
+
}
|
|
4990
|
+
|
|
4991
|
+
function skipTerminalEscape(text, index) {
|
|
4992
|
+
const next = text.charCodeAt(index);
|
|
4993
|
+
if (next >= 0x30 && next <= 0x7e) return index + 1;
|
|
4994
|
+
while (index < text.length && text.charCodeAt(index) >= 0x20 && text.charCodeAt(index) <= 0x2f) {
|
|
4995
|
+
index += 1;
|
|
4996
|
+
}
|
|
4997
|
+
if (index < text.length && text.charCodeAt(index) >= 0x30 && text.charCodeAt(index) <= 0x7e) {
|
|
4998
|
+
return index + 1;
|
|
4999
|
+
}
|
|
5000
|
+
return index;
|
|
5001
|
+
}
|
|
5002
|
+
|
|
5003
|
+
function stripTerminalControls(value) {
|
|
5004
|
+
const text = String(value ?? '');
|
|
5005
|
+
let output = '';
|
|
5006
|
+
let index = 0;
|
|
5007
|
+
|
|
5008
|
+
while (index < text.length) {
|
|
5009
|
+
const code = text.charCodeAt(index);
|
|
5010
|
+
if (code === 0x1b) {
|
|
5011
|
+
const next = text.charCodeAt(index + 1);
|
|
5012
|
+
if (next === 0x5d) {
|
|
5013
|
+
index = skipTerminalString(text, index + 2);
|
|
5014
|
+
} else if (next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) {
|
|
5015
|
+
index = skipTerminalString(text, index + 2);
|
|
5016
|
+
} else if (next === 0x5b) {
|
|
5017
|
+
index = skipTerminalCsi(text, index + 2);
|
|
5018
|
+
} else {
|
|
5019
|
+
index = skipTerminalEscape(text, index + 1);
|
|
5020
|
+
}
|
|
5021
|
+
continue;
|
|
5022
|
+
}
|
|
5023
|
+
if (code === 0x9b) {
|
|
5024
|
+
index = skipTerminalCsi(text, index + 1);
|
|
5025
|
+
continue;
|
|
5026
|
+
}
|
|
5027
|
+
if (code === 0x9d || code === 0x90 || code === 0x98 || code === 0x9e || code === 0x9f) {
|
|
5028
|
+
index = skipTerminalString(text, index + 1);
|
|
5029
|
+
continue;
|
|
5030
|
+
}
|
|
5031
|
+
if ((code <= 0x1f && code !== 0x09 && code !== 0x0a) || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
|
|
5032
|
+
index += 1;
|
|
5033
|
+
continue;
|
|
5034
|
+
}
|
|
5035
|
+
output += text[index];
|
|
5036
|
+
index += 1;
|
|
5037
|
+
}
|
|
5038
|
+
|
|
5039
|
+
return output;
|
|
5040
|
+
}
|
|
5041
|
+
|
|
5042
|
+
function printHumanJobRunLogs(logs) {
|
|
5043
|
+
if (Array.isArray(logs)) {
|
|
5044
|
+
for (const log of logs) {
|
|
5045
|
+
const line = typeof log === 'string' ? log : buildLogLineOf(log);
|
|
5046
|
+
process.stdout.write(`${stripTerminalControls(line)}\n`);
|
|
5047
|
+
}
|
|
5048
|
+
return;
|
|
5049
|
+
}
|
|
5050
|
+
const value = logs || '';
|
|
5051
|
+
process.stdout.write(`${stripTerminalControls(value)}${String(value).endsWith('\n') ? '' : '\n'}`);
|
|
5052
|
+
}
|
|
5053
|
+
|
|
5054
|
+
async function handleProjectRuns(session, flags) {
|
|
5055
|
+
const projectId = requireProjectId(flags, 'project runs');
|
|
5056
|
+
if (!projectId) return;
|
|
5057
|
+
validateProjectListFlags(flags, { maxLimit: 50 });
|
|
5058
|
+
const orgId = await resolveOrgId(session, flags);
|
|
5059
|
+
const query = buildQuery(flags, ['page', 'limit']);
|
|
5060
|
+
const data = jobRunsDataOf(unwrapData(await request(session, flags, 'GET', `/project/${projectId}/job-runs${query}`, { orgId })));
|
|
5061
|
+
|
|
5062
|
+
if (flags.json) return printJson(data);
|
|
5063
|
+
printJobRuns(data);
|
|
5064
|
+
}
|
|
5065
|
+
|
|
5066
|
+
async function handleProjectRunCancel(session, flags) {
|
|
5067
|
+
const projectId = requireProjectId(flags, 'project runs cancel');
|
|
5068
|
+
if (!projectId) return;
|
|
5069
|
+
if (!flags.run && !flags.runId || flags.run === true || flags.runId === true) {
|
|
5070
|
+
printCommandHelpAndFail('project runs cancel');
|
|
5071
|
+
return;
|
|
5072
|
+
}
|
|
5073
|
+
const runId = String(flags.run || flags.runId);
|
|
5074
|
+
const orgId = await resolveOrgId(session, flags);
|
|
5075
|
+
const data = unwrapData(await request(
|
|
5076
|
+
session,
|
|
5077
|
+
flags,
|
|
5078
|
+
'POST',
|
|
5079
|
+
`/project/${projectId}/job-runs/${encodeURIComponent(runId)}/cancel`,
|
|
5080
|
+
{ orgId, body: {} },
|
|
5081
|
+
));
|
|
5082
|
+
|
|
5083
|
+
if (flags.json) return printJson(sanitizeJobRunResponse(data));
|
|
5084
|
+
const run = data?.run || data;
|
|
5085
|
+
process.stdout.write(`Execucao: ${run?.status || 'cancelled'}\n`);
|
|
5086
|
+
}
|
|
5087
|
+
|
|
5088
|
+
async function handleProjectRunLogs(session, flags) {
|
|
5089
|
+
const projectId = requireProjectId(flags, 'project runs logs');
|
|
5090
|
+
if (!projectId) return;
|
|
5091
|
+
if (!flags.run && !flags.runId || flags.run === true || flags.runId === true) {
|
|
5092
|
+
printCommandHelpAndFail('project runs logs');
|
|
5093
|
+
return;
|
|
5094
|
+
}
|
|
5095
|
+
const runId = String(flags.run || flags.runId);
|
|
5096
|
+
const orgId = await resolveOrgId(session, flags);
|
|
5097
|
+
const data = sanitizeJobRunLogs(unwrapData(await request(
|
|
5098
|
+
session,
|
|
5099
|
+
flags,
|
|
5100
|
+
'GET',
|
|
5101
|
+
`/project/${projectId}/job-runs/${encodeURIComponent(runId)}/logs`,
|
|
5102
|
+
{ orgId },
|
|
5103
|
+
)));
|
|
5104
|
+
|
|
5105
|
+
if (flags.json) return printJson(data);
|
|
5106
|
+
const logs = typeof data === 'object' && data !== null && !Array.isArray(data) ? data.logs : data;
|
|
5107
|
+
printHumanJobRunLogs(logs);
|
|
5108
|
+
}
|
|
5109
|
+
|
|
4547
5110
|
async function handleProjectMetricsCapabilities(session, flags) {
|
|
4548
5111
|
const projectId = requireProjectId(flags, 'project metrics capabilities');
|
|
4549
5112
|
if (!projectId) return;
|
|
@@ -5536,6 +6099,10 @@ async function main() {
|
|
|
5536
6099
|
if (command === 'project' && subcommand === 'resume') return handleProjectLifecycleMutation(session, flags, 'resume');
|
|
5537
6100
|
if (command === 'project' && subcommand === 'delete') return handleProjectDelete(session, flags);
|
|
5538
6101
|
if (command === 'project' && subcommand === 'url') return handleProjectUrl(session, flags);
|
|
6102
|
+
if (command === 'project' && subcommand === 'runs' && positional[2] === 'cancel') return handleProjectRunCancel(session, flags);
|
|
6103
|
+
if (command === 'project' && subcommand === 'runs' && positional[2] === 'logs') return handleProjectRunLogs(session, flags);
|
|
6104
|
+
if (command === 'project' && subcommand === 'runs') return handleProjectRuns(session, flags);
|
|
6105
|
+
if (command === 'project' && subcommand === 'run' && positional[2] === 'logs') return handleProjectRunLogs(session, flags);
|
|
5539
6106
|
if (command === 'project' && subcommand === 'logs') return handleProjectLogs(session, flags);
|
|
5540
6107
|
if (command === 'project' && subcommand === 'metrics' && positional[2] === 'capabilities') return handleProjectMetricsCapabilities(session, flags);
|
|
5541
6108
|
if (command === 'project' && subcommand === 'metrics') return handleProjectMetrics(session, flags);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type_project": "job",
|
|
3
|
+
"image": {
|
|
4
|
+
"url": "ghcr.io/example/report:1.0.0",
|
|
5
|
+
"is_public": true
|
|
6
|
+
},
|
|
7
|
+
"envs": [
|
|
8
|
+
{
|
|
9
|
+
"name": "MODE",
|
|
10
|
+
"value": "daily"
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"storage": {
|
|
14
|
+
"persistent": false,
|
|
15
|
+
"capacity": 1
|
|
16
|
+
},
|
|
17
|
+
"job": {
|
|
18
|
+
"cron": "0 3 * * *"
|
|
19
|
+
}
|
|
20
|
+
}
|