Pular para o conteúdo principal

Crie seu primeiro módulo NNumbers em Java

Neste tutorial você cria o módulo notes: um serviço que guarda itens por organização e recarrega cada item em segundo plano. No caminho, você usa tudo o que um módulo de verdade usa — rota, Action assíncrona, permissão, banco com migration, auditoria, imagens e o pacote .nnp — e termina com o módulo instalado e visível na Console.

Faça os passos na ordem. Cada passo tem o comando exato e a saída esperada; onde a saída depende do ambiente (endereços, tokens), o texto explica o que conferir em vez de inventar valores.

Antes de começar​

  • Git, JDK 26 e um runtime de contêiner na máquina. O Maven Wrapper (./mvnw) e a ferramenta (./nnconsolew) vêm com o projeto.
  • Para os passos de banco, um MySQL 8.0.35 ou 8.4 local.
  • Para os passos 14 a 16 (upload e deploy), o endereço da Public API (NN_API_URL) e um token de plataforma (NN_TOKEN_CMD ou o device flow) — é o operador quem faz esses passos.

Passo 1. Crie o módulo​

Não há instalador do CLI: baixe o jar all publicado no repositório Maven da empresa — br.com.nnumbers.console:console-sdk-cli (0.8.0-SNAPSHOT), no grupo https://cicd.nnumbers.com.br/nexus/repository/maven-public/ (leitura sem credencial; o grupo também espelha o Maven Central):

MAVEN=https://cicd.nnumbers.com.br/nexus/repository/maven-public
SDK=0.8.0-SNAPSHOT # a versão do SDK desta linha
value=$(curl -sS "$MAVEN/br/com/nnumbers/console/console-sdk-cli/$SDK/maven-metadata.xml" \
| awk '/<classifier>all<\/classifier>/{f=1} f && /<value>/{gsub(/.*<value>|<\/value>.*/,""); print; exit}')
curl -sS -o nnconsole-all.jar \
"$MAVEN/br/com/nnumbers/console/console-sdk-cli/$SDK/console-sdk-cli-$value-all.jar"
java -jar nnconsole-all.jar version
# sdk 0.8.0-SNAPSHOT (nnconsole/0.8.0-SNAPSHOT), contrato console.nnumbers.io/v1alpha3

(O caminho -SNAPSHOT do arquivo 404; por isso o value do <snapshotVersions>.) Agora rode o gerador, apontando --dir para onde o módulo deve nascer — as flags vêm ANTES do id posicional (a ordem contrária é recusada com nnconsole: esperado exatamente um argumento: o id do módulo, exit 2):

java -jar nnconsole-all.jar module create --dir nnumbers-console-notes \
--package br.com.nnumbers.console.notes --name "Notes" notes

Dentro do módulo, o ./nnconsolew resolve o mesmo jar da versão do pom.xml sozinho — o jar baixado só é necessário para este primeiro módulo.

O gerador escreve a árvore do módulo (57 arquivos) e não toca em mais nada. Você fica com um repositório pronto para commit:

nnumbers-console-notes/
├── pom.xml mvnw nnconsolew Makefile README.md .gitignore
├── .mvn/ configuração do Maven Wrapper
├── src/main/java/br/com/nnumbers/console/notes/
│ ├── Application.java o main (serve | migrate | version)
│ ├── module/Module.java o id e o marcador da release
│ ├── app/AppConfiguration.java a fiação (migrator, Actions, periódicas)
│ ├── app/Http.java as respostas JSON do serviço
│ ├── app/ItemsController.java as rotas de @NnsRoute
│ ├── app/ActionsController.java a rota de estado de Action
│ └── store/{Item,ItemStore,Migrations}.java o banco e as migrations
├── src/main/resources/migrations/0001_items.up.sql (+ MANIFEST)
├── src/test/java/…/ testes (identidade, rota, banco)
├── nnp/ release.yaml api.yaml iam.yaml audit.yaml placement.yaml (+ audit/)
├── nns/service.yaml nns/api/openapi.yaml nns/helm/chart/ nns/helm/testdata/
├── status/status.yaml embed/embed.yaml help/help.yaml
├── docs/docs.yaml docs/pt-BR/index.md docs/pt-BR/items.md
├── tests/conformance.yaml onboarding/targets-reference.yaml

A árvore gerada não inclui projeto de Status Probe nem surface (NNC): o status/status.yaml descreve os componentes, e um módulo com UI ou com probe próprio carrega o projeto no repositório, ao lado da árvore gerada (o Demo, por exemplo, tem java/status-probe/ e nnc/).

Nada aqui é cópia: os descritores vivem um no lugar e o build os lê da raiz do repositório; o serviço os valida a cada partida, com os schemas do SDK.

Passo 2. Entenda a estrutura​

Três camadas, nesta ordem:

  1. O serviço (src/): Application sobe o runtime do SDK (serve), roda as migrations (migrate) ou imprime a release embutida (version). app/ tem as rotas (@NnsRoute("items.list")) e o domínio; store/ tem as instruções SQL, as migrations e os grants.
  2. Os descritores (nnp/, nns/service.yaml, status/, docs/, help/, embed/): o contrato da release. api.yaml lista rotas, iam.yaml permissões e papéis, audit.yaml trilhas e esquemas de evento, placement.yaml recursos e réplicas, service.yaml dependências de rede, slots de segredo, configuração e banco.
  3. O empacotamento (nns/helm/chart/, tests/): o chart que a plataforma renderiza e as expectativas da conformance.

Regra de ouro: se não está num descritor, não existe. O serviço não declara caminho de rota, permissão nem limite de corpo no código — tudo isso mora no api.yaml e é conferido na partida: rota do descritor sem método no controlador impede o serviço de subir, com a mensagem de qual rota falta.

Passo 3. Rode os testes​

./mvnw -q verify

O pom.xml gerado já declara o repositório Maven da empresa (nnumbers-public, o grupo do Nexus que também espelha o Maven Central): com um repositório local vazio e sem settings.xml, o ./mvnw resolve o console-service-parent e o SDK de lá.

Com um MySQL 8.0 ou 8.4 disponível, aponte o DSN no formato usuário:senha@tcp(host:porta)/ — a conta precisa de CREATE USER e de ALL … WITH GRANT OPTION sobre `nnt\_%` (o teste cria e apaga um banco nnt_notes_* próprio):

export NNC_TEST_MYSQL_DSN='nnstest@tcp(127.0.0.1:3306)/'
./mvnw verify

Saída esperada (trecho real):

[INFO] Running br.com.nnumbers.console.notes.store.ItemStoreMysqlTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.760 s -- in …ItemStoreMysqlTest
[INFO] Running br.com.nnumbers.console.notes.ApplicationTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.203 s -- in …ApplicationTest
[INFO] Running br.com.nnumbers.console.notes.app.ItemsControllerTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.077 s -- in …ItemsControllerTest
[INFO] Tests run: 15, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running br.com.nnumbers.console.notes.ApplicationJarIT
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.751 s -- in …ApplicationJarIT
[INFO] BUILD SUCCESS

O que cada teste guarda: ApplicationTest valida os descritores embutidos e as regras do contrato (toda permissão de rota existe no iam.yaml e é coberta por um papel; a rota assíncrona tem rota de estado; as trilhas de auditoria e a migration da release existem); ItemsControllerTest guarda a autorização (com a permissão passa; sem ela, 403 permission_denied) e a validação do corpo; ItemStoreMysqlTest roda o SQL em MySQL real (nome repetido no tenant = 409, item de outro tenant = 404); ApplicationJarIT prova o contrato de processo contra o JAR (version imprime a release e uso inválido sai com código 2). Sem NNC_TEST_MYSQL_DSN, os testes de banco são pulados com a mensagem; nunca fingidos.

Passo 4. Rode o serviço localmente​

O runtime espera exatamente o que o Module Manager injeta em produção: NN_* no ambiente, um arquivo de trust (quem pode chamar o serviço), um Service Registry (endereços lógicos) e os slots de segredo em disco. Para o desenvolvimento, você cria esses arquivos uma vez, fora do repositório (.local/ é ignorado pelo git):

mkdir -p .local

# 1. a chave do gateway (o emissor das chamadas verificadas) e a identidade do serviço
./nnconsolew keys generate --issuer nnumbers-public-api --out .local/gateway.seed > .local/jwks.json
./nnconsolew keys generate --issuer nns:notes --out .local/notes.seed >/dev/null

# 2. o trust aponta para a chave pública que o comando acima imprimiu
cat > .local/trust.yaml <<'EOF'
apiVersion: console.nnumbers.io/v1alpha3
kind: TrustConfig
issuers:
- issuer: nnumbers-public-api
profiles: [ctx]
jwks: {file: /caminho/absoluto/para/.local/jwks.json}
EOF

# 3. o registry aponta para o seu MySQL local
cat > .local/registry.yaml <<'EOF'
apiVersion: console.nnumbers.io/v1alpha3
kind: ServiceRegistry
environment: development
services:
mysql.internal.nnumbers: {url: "mysql://127.0.0.1:3306"}
audit.internal.nnumbers: {url: "http://127.0.0.1:18999", audience: "internal:audit"}
EOF

# 4. os slots de segredo do banco (crie o banco e o usuário no seu MySQL primeiro,
# com GRANT OPTION para a conta de migração: ela concede a DML do runtime)
mkdir -p .local/secrets/db-runtime .local/secrets/db-migrator
printf '%s' notes_dev > .local/secrets/db-runtime/username
printf '%s' '<senha>' > .local/secrets/db-runtime/password
printf '%s' notes_dev > .local/secrets/db-runtime/database
cp .local/secrets/db-runtime/{username,password,database} .local/secrets/db-migrator/
printf '%s' 'notes_dev@127.0.0.1' > .local/secrets/db-migrator/runtime_account
chmod 600 .local/secrets/*/password

Com isso, rode as migrations (uma vez por banco) e suba o serviço:

export NN_ENV=development NN_MODULE_ID=notes NN_SERVICE_ID=notes NN_VERSION=0.1.0 NN_RELEASE_ID=notes@0.1.0
export NN_TRUST_FILE="$PWD/.local/trust.yaml" NN_SERVICE_REGISTRY_FILE="$PWD/.local/registry.yaml"
export NN_SECRETS_DIR="$PWD/.local/secrets" NN_IDENTITY_KEY_FILE="$PWD/.local/notes.seed"

java -jar target/nns-notes-0.1.0.jar migrate
java -jar target/nns-notes-0.1.0.jar serve

Saída esperada do migrate (mensagens reais): nnsmysql: migrations aplicadas, nnsmysql: grants do runtime conferidos, notes: migração concluída. Com o serviço no ar, em outro terminal:

curl -s http://127.0.0.1:8080/live
# {"status":"ok"}

curl -s http://127.0.0.1:8080/ready
# {"component":"notes","kind":"module","version":"0.1.0","release_id":"notes@0.1.0","role":"active",
# "status":"healthy","checks":[{"name":"db","status":"healthy"},{"name":"identity","status":"healthy"},
# {"name":"schema","status":"healthy"},{"name":"trust:nnumbers-public-api","status":"healthy"}],
# "pending_actions":0,"checked_at":"…"}

/live é o processo vivo; /ready é o serviço pronto (banco, schema, identidade, trust e — quando o serviço tem rotas assíncronas — as Actions pendentes). Em produção, são esses dois que o Kubernetes usa como sondas.

Para parar: Ctrl-C. O encerramento é gracioso (para de aceitar, drena e sai com 0).

Passo 5. Acrescente uma rota​

Uma rota nasce em dois lugares: o descritor e o controlador.

  1. Declare no nnp/api.yaml (id, método, caminho, permissão, idempotência, limite de corpo):

    - {id: items.get, method: GET, path: "/items/{item_id}", permission: notes.item.read}
  2. Escreva o método no controlador, sem caminho nem método HTTP — o @NnsRoute casa pelo id do descritor, e o runtime já conferiu token e permissão antes de chamar:

    @RestController
    @Profile("serve")
    class ItemsController {
    private final ItemsService service;

    /** {@code items.get}: o item do tenant; de outro tenant, 404. */
    @NnsRoute("items.get")
    ResponseEntity<byte[]> get(Ctx ctx, ServiceContext sc, Match m) {
    Item it = ItemStore.get(ctx, service.db, sc.requireTenantId(), m.params().get("item_id"));
    return Http.json(200, Map.of("item", ItemsService.view(it)));
    }
    }
  3. Espelhe a rota no nns/api/openapi.yaml (o OpenAPI é conferido nos dois sentidos: rota sem operação e operação sem rota são erro):

    ./nnconsolew openapi check --dir nnp # openapi: ok (5 rota(s))
  4. Rode os testes. Rota do descritor sem método impede a partida, com a mensagem de qual rota ficou sem handler — o ./mvnw -q verify pega isso antes de qualquer deploy.

Dentro do método você recebe o que precisa do runtime: Ctx (correlação e prazo da rota), ServiceContext (o contexto verificado: tenant, principal, permissões), Match (parâmetros do caminho) e NnsBody (o corpo). Recusas são sempre throw ApiError.of(...), que vira o erro do contrato com código, reason e mensagem.

Passo 6. Acrescente uma Action​

Operações demoradas não seguram a chamada: elas viram Actions, visíveis no Action Center, com estado consultável.

  1. Declare a rota assíncrona no nnp/api.yaml — sem parâmetro de caminho (o runner da conformance só chama rotas sem parâmetro; o item vai no corpo):

    - {id: items.refresh, method: POST, path: /items/refresh, permission: notes.item.refresh,
    idempotency: required, mode: async, max_body_bytes: 4096}

    A rota de estado já existe desde o passo 1 (actions: {status_route: actions.get}).

  2. Na aceitação, crie a Action na mesma transação da resposta 202:

    Action act = Actions.newAction(ctx, actionConfig, REFRESH_TYPE,
    itemId.isEmpty() ? Map.of() : Map.of("item_id", itemId));
    if (!itemId.isEmpty()) {
    act = act.withResource("item", itemId);
    }
    actions.create(ctx, tx, act);
    return Actions.acceptedResponse(act); // 202 + Location + {"action": …}
  3. Escreva o handler que o Runner executa, idempotente (repetido depois de crash, grava o mesmo resultado):

    Map<String, Object> runRefresh(Ctx ctx, Action act) {
    RefreshInput in = StrictJson.unmarshal(act.input(), RefreshInput.class);
    // … recarrega o item (uma linha por item) …
    if (!ItemStore.recordRefresh(ctx, db, act.tenantId(), in.itemId(), now())) {
    throw ItemStore.notFound();
    }
    return Map.of("items", 1, "refreshed_at", …, "marker", Module.MARKER,
    "completed_by", config.releaseId());
    }
  4. Registre o handler no ActionRunner (app/AppConfiguration.java):

    ActionRunner.builder(store, config.releaseId())
    .handler(ItemsService.REFRESH_TYPE, service::runRefresh)
    .logger(logger.with("worker", "action-runner"))
    .adopt(service::activeRole);

O Runner roda em toda réplica, faz poll, renova o lease, tenta de novo e grava o evento terminal na trilha de auditoria (operations/item.refresh) na transação da transição. Quando a release ativa troca, o Runner da nova release adota as Actions que a antiga deixou em voo. No /ready, o campo pending_actions diz quantas estão em aberto.

Passo 7. Proteja com uma permissão e um papel​

  1. Declare a permissão no nnp/iam.yaml (id local, sem o produto na frente) e ligue-a a um papel:

    permissions:
    - {id: item.refresh, description: Iniciar a recarga assíncrona de um item}
    roles:
    operator: {description: "Leitura, criação e recarga",
    permissions: [item.read, item.create, item.refresh]}
  2. Use a permissão qualificada na rota (notes.item.refresh) — é assim que o gateway a expande e o serviço a confere de novo.

  3. Escreva o teste de autorização (é o que o ItemsControllerTest guarda):

    ServiceContext sc = context("items.refresh", "notes.item.refresh");
    assertThat(sc.can("notes.item.refresh")).isTrue();
    sc.require("notes.item.refresh"); // não lança

    ServiceContext denied = context("items.refresh" /* sem permissão */);
    assertThatThrownBy(() -> denied.require("notes.item.refresh"))
    .isInstanceOfSatisfying(ApiError.class,
    e -> assertThat(e.code()).isEqualTo(Code.PERMISSION_DENIED));

Papéis tenant viram realm roles nnc:notes:<papel> nos realms de cliente (o IAM garante as definições); papéis de plataforma existem só no realm nnumbers-platform e são atribuídos pelo operador. Errar o scope na primeira release é a decisão mais cara de mudar depois: permissão que já foi platform nunca volta a tenant.

Passo 8. Acrescente uma tabela com uma migration​

  1. Escreva o arquivo em src/main/resources/migrations/, um por versão, com uma instrução e o nome NNNN_<nome>.up.sql:

    CREATE TABLE notes_items (
    id CHAR(36) NOT NULL,
    tenant_id VARCHAR(64) NOT NULL,
    name VARCHAR(80) NOT NULL,
    refreshed_at DATETIME(3) NULL,
    created_at DATETIME(3) NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY notes_items_name (tenant_id, name),
    KEY notes_items_list (tenant_id, created_at, id)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
  2. Liste o arquivo no MANIFEST da pasta (o conjunto é fixado por lista — o serviço não varre diretório em tempo de execução).

  3. Declare os grants da DML do runtime em store/Migrations.java:

    public static final List<TableGrant> GRANTS =
    List.of(TableGrant.of("notes_items", "SELECT", "INSERT", "UPDATE", "DELETE"));

Migrations são expand-only: uma aplicada nunca muda (o checksum fica no ledger nn_schema_migrations e é conferido a cada partida); release nova só acrescenta arquivo. É isso que permite upgrade e rollback sem perder dados. No migrate, a conta de migração aplica o DDL e concede a DML do runtime; o serviço nunca tem DDL. O teste ItemStoreMysqlTest cobre isso em banco real, e ele só passa se a migration, o MANIFEST e os grants estiverem certos.

Passo 9. Emita um evento de auditoria​

  1. Declare a trilha e o evento no nnp/audit.yaml, com o schema do payload:

    trails:
    items:
    audience: tenant
    events:
    item.create: {schema_version: 1, description: Item criado,
    payload_schema: audit/items/item.create.v1.schema.json}
  2. No schema (nnp/audit/items/item.create.v1.schema.json), só os campos que o evento leva.

  3. Emita dentro da transação da mudança de domínio:

    AuditEvent ev = AuditEvent.newEvent(ctx, Origin.publicRoute(sc), auditConfig,
    "items", "item.create", 1)
    .withResource("item", it.id())
    .withDetail("name", it.name());
    emitter.emit(ctx, tx, ev);

O evento vai para o outbox na mesma transação do INSERT: ou os dois existem, ou nenhum. O SDK entrega o outbox com a identidade nns:notes. Leituras não auditam; as operações assíncronas ganham o evento terminal no fim (passo 6). O contrato é append-only: uma trilha nova pode ser acrescentada, mas o schema de um evento já registrado nunca muda — crie a versão seguinte (item.create v2) se o payload precisar crescer.

Passo 10. Cuide da saúde​

O serviço já responde /live e /ready desde o passo 1 (são rotas embutidas, sempre liberadas; não passam pelo gateway nem por permissão). Além delas, o módulo declara o Status Probe: um componente por pergunta "como sei se o produto funciona?".

# status/status.yaml
components:
- {id: api, display_name: "API", audience: public, impact: major,
description: "Notes: API de itens e recargas"}

O probe é uma imagem própria (java/status-probe/), sem framework, que olha o /ready do NNS da própria release:

public static void main(String[] args) {
StatusProbe.main(new Probe.Options(), (ctx, p) -> p.check("api", Checks.ready(p.self())));
}

O componente audience: public aparece na página pública de status; os demais ficam na Platform Console. Configure o intervalo e os limiares no status/status.yaml (o avaliador usa os mesmos valores para decidir que uma observação está velha).

Passo 11. Construa a imagem do serviço​

./mvnw spring-boot:build-image

A imagem é construída pelo Paketo (non-root, JVM), sem Dockerfile. Duas coisas do ambiente: um runtime de contêiner na máquina, e — para publicar — a credencial de registry (fornecida pela equipe da plataforma num DOCKER_CONFIG; nunca docker login -p) e o PUSH=1:

PUSH=1 make image # publica e mostra o digest

Guarde a referência publicada, por digest, em nns/build/image.ref; o pacote lê a imagem daí (a tag é só conveniência; o que vale é o digest). O Status Probe tem imagem própria (make status-image e status/build/image.ref) — mas o projeto do probe é seu: a árvore gerada não o traz, e o alvo explica isso enquanto ele não existir (veja §Status Probe do README do módulo). O Makefile recusa construir com a árvore suja: o digest tem de corresponder ao commit empacotado.

Passo 12. Empacote o NNP​

make package

O alvo roda o nnp build com os descritores, o chart, as docs, o help e o SBOM, e depois o nnp verify:

./nnconsolew nnp build --root . --out dist \
--java-image "$(cat nns/build/image.ref)" \
--probe-java-image "$(cat status/build/image.ref)"
./nnconsolew nnp verify --signature-policy off dist/notes-0.1.0.nnp

O resultado é dist/notes-0.1.0.nnp: o pacote único da release, com a árvore de descritores, o chart, as imagens (por digest), as páginas de docs, os tópicos de help, o SBOM e o release.json que amarra tudo ao commit. O mesmo commit produz o mesmo pacote.

O make package lê as imagens publicadas por digest (nns/build/image.ref e status/build/image.ref): sem um runtime de contêiner na máquina não há como construí-las, sem a credencial de registry — que a equipe da plataforma fornece num DOCKER_CONFIG — não há como publicá-las, e a assinatura (nnp sign, passo 13) é do operador, com a chave dele. O seu caminho local termina em dist/notes-0.1.0.nnp.

Passo 13. Valide o pacote​

./nnconsolew nnp verify --signature-policy off --json dist/notes-0.1.0.nnp \
| jq -r '.report.checks[] | "\(.id) \(.status)"'

O verify confere o que o Module Manager vai conferir: limites e nomes das entradas, duplicatas, allowlist de conteúdo, canonicidade do pacote, checksums, metadados da release, descritores, integridade das docs e do chart, compatibilidade de SDK, SBOM e o scan de segredos. Para uma release de produção, o pacote é assinado pelo operador e a validação usa a política required:

./nnconsolew nnp sign --key-file <seed do operador> --out dist/notes-0.1.0.signed.nnp dist/notes-0.1.0.nnp
./nnconsolew nnp verify --signature-policy required --trusted-keys <jwks> dist/notes-0.1.0.signed.nnp

O nnp verify local é a sua rede de segurança; o Report do Module Manager (passo 15) é o veredito.

Passo 14. Faça o upload​

Esta parte é do operador (a identidade é de plataforma):

export NN_API_URL=https://api-nn.nnumbers.com.br
export NN_TOKEN_CMD='<comando que imprime o token no stdout>' # ou o device flow

./nnconsolew modules upload dist/notes-0.1.0.signed.nnp --api "$NN_API_URL"

O comando acompanha a Action de validação até o fim e imprime o estado da release. Repetir com a mesma Idempotency-Key é seguro; o padrão é gerar uma nova. O upload valida o pacote e guarda a release — ele não provisiona nada.

Passo 15. Instale o módulo​

./nnconsolew modules plan notes notes@0.1.0 # READY ou os bloqueios, com dono e remediação
./nnconsolew modules deploy notes notes@0.1.0 --api "$NN_API_URL"

O primeiro deploy cria o bootstrap (namespace, banco, principais, segredos, grants) e faz o stage e a ativação da release: NNS, Status Probe, rotas no gateway, permissões, auditoria, docs e help. Tudo numa Action só, com fases (PREFLIGHT → PROVISIONING → MIGRATING → DEPLOYING → REGISTERING → ACTIVATING → ACTIVE). Falha com checkpoint é repetível: deploy de novo retoma de onde parou, sem criar nada duas vezes.

Passo 16. Veja o módulo na Console e acesse​

Depois da ativação:

  • Em ./nnconsolew modules show notes notes@0.1.0, a release está ACTIVE com os checks de rede, status, docs e help passed.

  • A Platform Console lista o módulo em PLATAFORMA → Módulos, com a release ativa; as páginas de docs da release aparecem no portal e os tópicos de help aparecem no Help Panel de quem tem a permissão.

  • As rotas respondem pelo gateway, com o token de quem chama — é o gateway que autentica e o serviço que confere a permissão outra vez:

    curl -s "$NN_API_URL/v1/notes/items" -H "Authorization: Bearer $TOKEN"
    # {"items": []} # logo depois do deploy

    curl -s -X POST "$NN_API_URL/v1/notes/items" \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -H "Idempotency-Key: 0d1f4a2c-8f0b-4b6e-9d5a-1c2b3a4d5e6f" \
    -d '{"name": "Primeiro item"}'
    # {"item": {"id": "…", "name": "Primeiro item", "refreshed_at": null, "created_at": "…"}}

O notes nasce sem interface própria (nenhuma surface declarada): ele aparece como serviço de plataforma e pela API. Um módulo com UI declara a surface no release.yaml e o front é carregado na Console — o ciclo de vida (upload, deploy, rollback) é o mesmo.

Passo 17. Publique uma nova versão​

Suba a versão nos lugares que a carregam juntos — nnp/release.yaml, nns/api/openapi.yaml, nns/helm/chart/Chart.yaml, nns/helm/testdata/injected-values.yaml e o marcador em Module.java — e aponte a N-1 nas expectativas da conformance:

# tests/conformance.yaml
release: {previous: notes@0.1.0}

Depois, o mesmo caminho do primeiro deploy:

./mvnw -q verify && make package
./nnconsolew modules upload dist/notes-0.1.1.signed.nnp --api "$NN_API_URL"
./nnconsolew modules deploy notes notes@0.1.1 --api "$NN_API_URL"

O upgrade só reaplica o que a release muda: o bootstrap é reconferido (exists_ok), não recriado. As migrations continuam expand-only: a N-1 e a N enxergam o mesmo banco.

Passo 18. Volte para a versão anterior​

./nnconsolew modules rollback notes --to notes@0.1.0 --api "$NN_API_URL"

O rollback é acoplado: volta a release inteira (NNS, probe, rotas, permissões, docs, help e rede da release) para a N-1, de uma vez. Ele reverte só recursos de release: namespace, banco, principais e segredos são do módulo e continuam (o plano seguinte os mostra exists_ok). Por isso as migrations são expand-only e nenhum item muda de audience entre releases consecutivas: crie item novo em vez de mudar o que já existe.

Na primeira release da linha não há N-1 para onde voltar: ela declara release: {previous: none, previous_reason: no_same_line_previous} e os itens de rollback saem not_applicable com o motivo; a saída, se precisar, é disable ou uma versão nova.

Próximos passos​

Você tem um módulo no ar. Para o ciclo completo — repositório e aprovações, domínio, descritores, provas locais, publicação, provas ao vivo, upgrade, rollback e o relatório final — siga o ciclo de vida de um módulo.