diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index c215a763d..a251e504f 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -1,6 +1,18 @@ name: Install Codefly description: Install the Codefly CLI version pinned by this action reference +inputs: + authority-token: + description: >- + A token with contents read access to codefly-dev/authority, which is private and whose + interface client, hop and delivery libraries the CLI requires. The CLI + workflows use the existing organization GH_PAT secret. It reaches git + for the build step alone. Without it the build resolves from the module + cache only, so a workflow that has not downloaded the CLI's modules + already cannot install it. + required: false + default: "" + runs: using: composite steps: @@ -14,13 +26,20 @@ runs: shell: bash env: CODEFLY_REPOSITORY_ROOT: ${{ github.action_path }}/../../.. + AUTHORITY_TOKEN: ${{ inputs.authority-token }} + GOPRIVATE: github.com/codefly-dev/authority run: | set -euo pipefail install_root="${RUNNER_TEMP}/codefly-action/bin" mkdir -p "${install_root}" ( cd "${CODEFLY_REPOSITORY_ROOT}" - GOWORK=off GOBIN="${install_root}" go install ./cmd/codefly + export GOWORK=off + if [ -n "${AUTHORITY_TOKEN}" ]; then + .github/scripts/download-go-modules.sh + fi + unset AUTHORITY_TOKEN + GOBIN="${install_root}" go install ./cmd/codefly ) test -x "${install_root}/codefly" echo "${install_root}" >> "${GITHUB_PATH}" diff --git a/.github/actions/read-authority/action.yml b/.github/actions/read-authority/action.yml new file mode 100644 index 000000000..281244086 --- /dev/null +++ b/.github/actions/read-authority/action.yml @@ -0,0 +1,39 @@ +name: Read the authority's libraries +description: >- + Mints a read-only token for codefly-dev/authority with the CI reader app, as + obin-ai/module-directory's and module-compute-engine's actions of the same + name do. The CLI requires the authority's interface client, hop and delivery + libraries (libraries/authority-client/go, libraries/hop/go, + libraries/delivery/go), and that repository is private. The app must be + installed on the codefly-dev organization with read access to it. + + It writes no git configuration: it answers the token, and the one step that + downloads modules (.github/scripts/download-go-modules.sh) lets git use it + for that step alone, so no later step (build, vet, lint, a test a pull + request supplies) can read it. + +inputs: + app-id: + description: The CI reader app's id (secrets.CI_READER_GITHUB_APP_ID). + required: true + private-key: + description: The CI reader app's key (secrets.CI_READER_GITHUB_APP_PRIVATE_KEY). + required: true + +outputs: + token: + description: A read-only token for codefly-dev/authority. + value: ${{ steps.reader.outputs.token }} + +runs: + using: composite + steps: + - name: Mint the read-only authority token + id: reader + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.private-key }} + owner: codefly-dev + repositories: authority + permission-contents: read diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c11720fa5..a42abe4c5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -56,6 +56,7 @@ updates: directories: - / - /.github/actions/install + - /.github/actions/read-authority schedule: interval: weekly open-pull-requests-limit: 1 diff --git a/.github/scripts/download-go-modules.sh b/.github/scripts/download-go-modules.sh new file mode 100755 index 000000000..d0ef50e7e --- /dev/null +++ b/.github/scripts/download-go-modules.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# download-go-modules — `go mod download` for the module in the current +# directory, with git reading codefly-dev/authority through AUTHORITY_TOKEN. +# +# The CLI requires the authority's interface client, hop and delivery libraries, +# and codefly-dev/authority is private. `go` fetches it with git, so the token +# reaches git through this process's environment (GIT_CONFIG_*), rewriting that +# one repository's URL and nothing else: no git configuration is written, and +# no later step of the job, nor any other github.com fetch, can see it. Every +# step after this one builds from the module cache this fills. +# +# AUTHORITY_TOKEN is supplied by the caller (CI uses the organization GH_PAT). +# The repository's path must be private to go (GOPRIVATE), so the module proxy +# and the checksum database are never asked about it. +set -euo pipefail + +: "${AUTHORITY_TOKEN:?AUTHORITY_TOKEN is required: supply a token with contents read access to codefly-dev/authority}" + +authority=github.com/codefly-dev/authority +for setting in GONOPROXY GONOSUMDB; do + case ",$(go env "$setting")," in + *,github.com/codefly-dev,* | *,github.com/codefly-dev/\*,* | *,"$authority",*) ;; + *) + echo "::error::$setting does not cover $authority; set GOPRIVATE=$authority so neither the module proxy nor the checksum database is asked for it" >&2 + exit 1 + ;; + esac +done + +n="${GIT_CONFIG_COUNT:-0}" +export "GIT_CONFIG_KEY_${n}=url.https://x-access-token:${AUTHORITY_TOKEN}@${authority}.insteadOf" +export "GIT_CONFIG_VALUE_${n}=https://${authority}" +export GIT_CONFIG_COUNT=$((n + 1)) +unset AUTHORITY_TOKEN + +go mod download "$@" diff --git a/.github/workflows/companions.yaml b/.github/workflows/companions.yaml index 5ef3bf484..058e7a1a3 100644 --- a/.github/workflows/companions.yaml +++ b/.github/workflows/companions.yaml @@ -36,6 +36,12 @@ concurrency: group: companions-publish cancel-in-progress: false +env: + # The CLI requires codefly-dev/authority's libraries, and that repository is + # private: fetched from GitHub with the existing organization GH_PAT, never via the + # module proxy or the checksum database (go.yml says how). + GOPRIVATE: github.com/codefly-dev/authority + jobs: publish: name: Publish and verify companion images @@ -82,9 +88,16 @@ jobs: go-version-file: cli/go.mod cache: false + # The token reaches git in the download alone; the build reads the cache. - name: Build the codefly CLI working-directory: cli - run: GOWORK=off GOBIN="${{ github.workspace }}/bin" go install ./cmd/codefly + env: + AUTHORITY_TOKEN: ${{ secrets.GH_PAT }} + GOWORK: "off" + run: | + .github/scripts/download-go-modules.sh + unset AUTHORITY_TOKEN + GOBIN="${{ github.workspace }}/bin" go install ./cmd/codefly # A multi-platform buildx push builds every image as one manifest, so the # arm64 stages need an emulator (QEMU) and a builder that can emit a @@ -228,9 +241,16 @@ jobs: go-version-file: cli/go.mod cache: false + # The token reaches git in the download alone; the build reads the cache. - name: Build the codefly CLI working-directory: cli - run: GOWORK=off GOBIN="${{ github.workspace }}/bin" go install ./cmd/codefly + env: + AUTHORITY_TOKEN: ${{ secrets.GH_PAT }} + GOWORK: "off" + run: | + .github/scripts/download-go-modules.sh + unset AUTHORITY_TOKEN + GOBIN="${{ github.workspace }}/bin" go install ./cmd/codefly - name: Verify linux/arm64 assets for the qualification fixtures # Test inputs only: these identities never select production agents or diff --git a/.github/workflows/container-recovery-native.yml b/.github/workflows/container-recovery-native.yml index a93d3933b..2574f56e2 100644 --- a/.github/workflows/container-recovery-native.yml +++ b/.github/workflows/container-recovery-native.yml @@ -14,6 +14,12 @@ on: permissions: contents: read +env: + # The CLI requires codefly-dev/authority's libraries, and that repository is + # private: fetched from GitHub with the existing organization GH_PAT, never via the + # module proxy or the checksum database (go.yml says how). + GOPRIVATE: github.com/codefly-dev/authority + concurrency: group: container-recovery-native-${{ github.ref }} cancel-in-progress: true @@ -53,6 +59,14 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod + # Reading the CLI's Core pin and the qualification test below resolve + # the CLI's modules from the cache this fills; only this step has the + # token. + - name: Download the CLI's modules + env: + GOWORK: 'off' + AUTHORITY_TOKEN: ${{ secrets.GH_PAT }} + run: .github/scripts/download-go-modules.sh - name: Check out the ${{ matrix.agent }} agent source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index f40fb8332..5e908ba34 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,6 +22,16 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + # The CLI requires codefly-dev/authority's interface client, hop and delivery + # libraries, and that repository is private: fetched from GitHub with the + # existing organization GH_PAT (handed to git by + # .github/scripts/download-go-modules.sh for the download step alone), never + # via the module proxy or the checksum database. GOPRIVATE is the default of + # both GONOPROXY and GONOSUMDB. Every other codefly-dev module is public and + # stays on the proxy and the checksum database. + GOPRIVATE: github.com/codefly-dev/authority + jobs: quality: name: ${{ matrix.gate }} @@ -88,15 +98,22 @@ jobs: restore-keys: | cli-go-build-v3-${{ matrix.gate }}-${{ runner.os }}- + # Every gate resolves the CLI's modules, codefly-dev/authority's among + # them. The token is handed to the install action and the download step + # only; nothing writes it into git's configuration. # This is both the canonical bootstrap and the cross-repository action # contract consumed by Codefly products. Dogfood the local action so a # release cannot publish an installer that CI never exercised. - name: install Codefly if: matrix.gate == 'bootstrap-audit' || matrix.gate == 'control-integration' uses: ./.github/actions/install + with: + authority-token: ${{ secrets.GH_PAT }} - name: Install Dependencies - run: go mod download + env: + AUTHORITY_TOKEN: ${{ secrets.GH_PAT }} + run: .github/scripts/download-go-modules.sh # govulncheck gate via the CLI (replaces scripts/govulncheck.sh): # suppressions + staleness from .govulncheck.yaml, fails only on @@ -286,6 +303,14 @@ jobs: go-version-file: go.mod cache: false + # The linter loads every package, so the modules must be in the cache + # before it runs, codefly-dev/authority's among them; the linter itself + # never sees the token. + - name: Download the CLI's modules + env: + AUTHORITY_TOKEN: ${{ secrets.GH_PAT }} + run: .github/scripts/download-go-modules.sh + - name: golangci-lint uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0689f3b2b..c6d0689bb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -15,6 +15,16 @@ permissions: attestations: write artifact-metadata: write +env: + # The CLI requires codefly-dev/authority's interface client, hop and delivery + # libraries, and that repository is private: fetched from GitHub with the + # existing organization GH_PAT, never via the module proxy or the checksum database + # (GOPRIVATE is the default of GONOPROXY and GONOSUMDB). The release builds + # from a module cache the host fills before any build; the token never enters + # the release container. This is why releases are the install path: a + # `go install` from outside the organization cannot read the repository. + GOPRIVATE: github.com/codefly-dev/authority + jobs: release: name: Qualify and publish @@ -32,6 +42,17 @@ jobs: go-version-file: go.mod cache: false + # The module cache lives under RUNNER_TEMP, which the release container + # mounts at the same path, so the container builds every target from what + # this step downloaded and needs no credential of its own. + - name: Download the CLI's modules + env: + AUTHORITY_TOKEN: ${{ secrets.GH_PAT }} + run: | + gomodcache="$RUNNER_TEMP/gomodcache" + echo "GOMODCACHE=$gomodcache" >> "$GITHUB_ENV" + GOMODCACHE="$gomodcache" .github/scripts/download-go-modules.sh + - name: Install Syft id: syft uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 @@ -80,6 +101,8 @@ jobs: # tag is qualified with CGO and real compilers for every target. docker run --rm \ -e CODEFLY_RELEASE_SIGNING_KEY_PATH \ + -e GOMODCACHE \ + -e GOPRIVATE \ -e RELEASE_TAG \ -v "$GITHUB_WORKSPACE:/workspace" \ -v "$RUNNER_TEMP:$RUNNER_TEMP" \ @@ -89,6 +112,8 @@ jobs: check docker run --rm \ -e CODEFLY_RELEASE_SIGNING_KEY_PATH \ + -e GOMODCACHE \ + -e GOPRIVATE \ -e RELEASE_TAG \ -v "$GITHUB_WORKSPACE:/workspace" \ -v "$RUNNER_TEMP:$RUNNER_TEMP" \ @@ -135,6 +160,8 @@ jobs: docker run --rm \ -e CODEFLY_RELEASE_SIGNING_KEY_PATH \ -e GITHUB_TOKEN \ + -e GOMODCACHE \ + -e GOPRIVATE \ -e GH_TOKEN \ -e HOMEBREW_TAP_GITHUB_TOKEN \ -e MACOS_SIGN_P12 \ diff --git a/README.md b/README.md index 8c051938a..f7c0882b2 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,20 @@ GitHub release for each immutable version. See [CLI releases and updates](docs/cli-updates.md) for direct installation, update commands, verification, rollback, and package-manager ownership. +The release is the install path. The CLI requires libraries from +`codefly-dev/authority`, which is a private repository, so `go install +github.com/codefly-dev/cli/...` (the CLI or `codefly-help`) is not supported +from outside the codefly-dev organization while that repository stays private: +the module proxy cannot serve it and `go` cannot fetch it without read access. +Releases ship prebuilt, signed binaries and need no access to it. + +Building from source needs read access to `codefly-dev/authority` and +`GOPRIVATE=github.com/codefly-dev/authority`, so `go` fetches it with git +(your own credentials) rather than asking the public proxy and checksum +database. CI reads it with the organization's CI reader app +(`.github/actions/read-authority`, handed to git for the download step alone by +`.github/scripts/download-go-modules.sh`). + # Development ## Repository layout diff --git a/cmd/ci/deploy.go b/cmd/ci/deploy.go index 22fe654a1..7f189a552 100644 --- a/cmd/ci/deploy.go +++ b/cmd/ci/deploy.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/codefly-dev/cli/pkg/gitops" + "github.com/codefly-dev/cli/cmd/common" "github.com/codefly-dev/cli/pkg/cli" "github.com/codefly-dev/cli/pkg/orchestration" @@ -70,7 +72,7 @@ func initDeployService(ctx context.Context, workspace *resources.Workspace, modu return nil, nil, w.Wrap(err) } - flow, err := orchestration.NewFlow(ctx, workspace, module, service, env, orchestration.DeployMode) + flow, err := orchestration.NewFlow(ctx, workspace, module, service, env, orchestration.DeployMode, orchestration.WithCompositionCheck(gitops.PermissionPreflight(true)), orchestration.WithRunModuleClosure(module.Name)) if err != nil { return nil, nil, w.Wrap(err) } diff --git a/cmd/ci/permission_preflight_test.go b/cmd/ci/permission_preflight_test.go new file mode 100644 index 000000000..3018620b7 --- /dev/null +++ b/cmd/ci/permission_preflight_test.go @@ -0,0 +1,49 @@ +package ci + +import ( + "os" + "path/filepath" + "testing" + + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" +) + +func TestCIDeployRejectsInvalidRoleBeforeStartingAgents(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "workspace.codefly.yaml": "name: preflight\nlayout: modules\nmodules:\n - name: app\n", + "modules/app/module.codefly.yaml": "kind: module\nname: app\nservices:\n - name: api\n", + "modules/app/services/api/service.codefly.yaml": "kind: service\nname: api\nversion: 0.0.0\nmodule: app\n", + "modules/app/module.contract.codefly.yaml": `schema: codefly/module-contract/v1 +principal: app +namespaces: [] +queues: [] +scope_ceilings: + - {resource_kind: app.entry, actions: [read]} +roles: + - name: installer + scopes: + - {resource_kind: app.entry, actions: [typo]} +bindings: [] +destinations: [] +`, + } + for path, data := range files { + full := filepath.Join(root, path) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0755)) + require.NoError(t, os.WriteFile(full, []byte(data), 0644)) + } + workspace, err := resources.LoadWorkspaceFromDir(t.Context(), root) + require.NoError(t, err) + module, err := workspace.LoadModuleFromName(t.Context(), "app") + require.NoError(t, err) + service, err := module.LoadServiceFromName(t.Context(), "api") + require.NoError(t, err) + previous := envInput + envInput = "local" + t.Cleanup(func() { envInput = previous }) + flow, _, err := initDeployService(t.Context(), workspace, module, service) + require.Nil(t, flow) + require.ErrorContains(t, err, "app.entry:typo") +} diff --git a/cmd/common/context.go b/cmd/common/context.go index ed8fd6fb2..1c9b8f6e9 100644 --- a/cmd/common/context.go +++ b/cmd/common/context.go @@ -5,7 +5,6 @@ import ( "github.com/codefly-dev/cli/pkg/cli" "github.com/codefly-dev/cli/pkg/composition" - "github.com/codefly-dev/core/agents" resources "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/wool" ) @@ -30,11 +29,8 @@ func NewContext() (context.Context, func()) { provider.WithLogger(cli.GetLogger()) - // Enable file logging to ~/.codefly/logs/.log + // Repeated helper contexts share the invocation's single registered sink. cli.EnableFileLogging() - if fl := cli.GetFileLogger(); fl != nil { - agents.AddProcessor(fl) - } ctx = provider.Inject(ctx) diff --git a/cmd/common/context_logging_test.go b/cmd/common/context_logging_test.go new file mode 100644 index 000000000..8be5a6e3c --- /dev/null +++ b/cmd/common/context_logging_test.go @@ -0,0 +1,39 @@ +package common + +import ( + "encoding/json" + "os" + "os/exec" + "strings" + "testing" + + "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/core/agents" + "github.com/codefly-dev/core/wool" + "github.com/stretchr/testify/require" +) + +func TestRepeatedContextsKeepOneInvocationLogAndForwardOneCopy(t *testing.T) { + if os.Getenv("CODEFLY_CONTEXT_LOG_TEST") != "1" { + cmd := exec.Command(os.Args[0], "-test.run=^TestRepeatedContextsKeepOneInvocationLogAndForwardOneCopy$") + cmd.Env = append(os.Environ(), "CODEFLY_HOME="+t.TempDir(), "CODEFLY_CONTEXT_LOG_TEST=1") + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + return + } + cli.SuppressOutput() + _, done := NewContext() + first := cli.LogFilePath() + cli.GetFileLogger().Process(&wool.Log{Level: wool.INFO, Message: "preparation-retained"}) + done() // workspace/solution preparation ends before service delegation. + _, done = NewContext() + defer done() + require.Equal(t, first, cli.LogFilePath()) + data, err := json.Marshal(agents.LogMessage{Log: &wool.Log{Level: wool.INFO, Message: "forwarded-once"}}) + require.NoError(t, err) + agents.GetLogHandler().ForwardLogs(strings.NewReader(string(data) + "\n")) + actual, err := os.ReadFile(first) + require.NoError(t, err) + require.Equal(t, 1, strings.Count(string(actual), "preparation-retained")) + require.Equal(t, 1, strings.Count(string(actual), "forwarded-once"), "nested contexts must not register duplicate agent processors") +} diff --git a/cmd/config/config.go b/cmd/config/config.go index f46e21682..bcd1aebe5 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -44,6 +44,14 @@ deploy time: /configurations//[.secret].env. Configuration is Codefly's, so provisioning it is a Codefly operation — not a shell script in your repository. Values are written but never printed. +A workspace may add to an imported group with configuration-extensions in +workspace.codefly.yaml. Env keys must be disjoint unless append explicitly +names the key and its delimiter (for example edge: {append: {MODULES: ","}}). +YAML mappings combine recursively and sequences append; duplicate scalar keys +are refused by name. Both an own and an imported group must exist. Without +this declaration, an own group replaces the imported group whole. +See docs/commands.md#extending-imported-workspace-configuration. + Secrets are refused when git would track the destination file, are stored 0600 inside a 0700 directory, and are never overwritten without --force so re-running setup cannot rotate a credential another service already holds.`, diff --git a/cmd/deploy/gitops.go b/cmd/deploy/gitops.go index fa92d4e87..23708fc64 100644 --- a/cmd/deploy/gitops.go +++ b/cmd/deploy/gitops.go @@ -710,6 +710,10 @@ func renderComposition(ctx context.Context) error { if err != nil { return err } + if err = gitops.CheckCompositionPermissions(ctx, workspace, env, true); err != nil { + return err + } + modules, err := workspace.LoadModules(ctx) if err != nil { return err diff --git a/cmd/deploy/module.go b/cmd/deploy/module.go index 4c0b4e994..03cdf6905 100644 --- a/cmd/deploy/module.go +++ b/cmd/deploy/module.go @@ -56,6 +56,9 @@ var ModuleCmd = &cobra.Command{ if err != nil { return err } + if err = gitops.CheckCompositionPermissions(ctx, workspace, env, true, module.Name); err != nil { + return err + } if renderOnly { cli.Header(2, "render-only mode — manifests written to disk, no kubectl apply") result, resultErr := gitops.NewCoordinator().Render(ctx, gitops.ProduceRequest{ diff --git a/cmd/deploy/permission_preflight_test.go b/cmd/deploy/permission_preflight_test.go new file mode 100644 index 000000000..f9caf0f70 --- /dev/null +++ b/cmd/deploy/permission_preflight_test.go @@ -0,0 +1,64 @@ +package deploy + +import ( + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" +) + +func TestCompositionRenderChecksLaterModuleBeforeFirstRender(t *testing.T) { + t.Setenv(resources.CodeflyHomeEnv, t.TempDir()) + root := t.TempDir() + t.Chdir(root) + previous := gitOpsEnv + gitOpsEnv = "staging" + t.Cleanup(func() { gitOpsEnv = previous }) + descriptor, err := proto.Marshal(&descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{{Name: proto.String("api.proto"), Package: proto.String("bad"), Syntax: proto.String("proto3"), Service: []*descriptorpb.ServiceDescriptorProto{{Name: proto.String("Api"), Method: []*descriptorpb.MethodDescriptorProto{{Name: proto.String("Unmarked")}}}}}}}) + require.NoError(t, err) + files := map[string]string{ + resources.WorkspaceConfigurationName: "name: preflight\nlayout: modules\nmodules:\n - name: alpha\n - name: zulu\nenvironments:\n - name: staging\n", + "modules/alpha/module.codefly.yaml": "kind: module\nname: alpha\n", + "modules/zulu/module.codefly.yaml": "kind: module\nname: zulu\n", + "modules/zulu/module.package.codefly.yaml": fmt.Sprintf(`schema: codefly/module-package/v2 +kind: module-package +id: example/zulu +version: 0.1.0 +minimum-codefly-version: ">=0.3.32 <1.0.0" +artifact-roots: [contracts] +services: + - name: api + endpoints: [grpc] + api-contracts: + - endpoint: grpc + kind: protobuf + package: bad + path: contracts/api.binpb + digest: sha256:%x +contracts: + composition: ">=2.0.0 <3.0.0" +`, sha256.Sum256(descriptor)), + "modules/zulu/contracts/api.binpb": string(descriptor), + // Zulu holds every method it publishes to a policy wherever it is + // delivered, so its unmarked method refuses even with no host. + "modules/zulu/module.contract.codefly.yaml": "schema: codefly/module-contract/v1\nprincipal: zulu\nnamespaces: []\nqueues: []\nscope_ceilings: []\nbindings: []\ndestinations: []\nrequire_method_policies: true\n", + } + for name, content := range files { + path := filepath.Join(root, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + } + // Alpha would enter the coordinator first. The later invalid policy must + // be reported before even that first module's readiness/render pipeline. + err = renderComposition(t.Context()) + require.ErrorContains(t, err, "/bad.Api/Unmarked declares no permission policy") + require.ErrorContains(t, err, "require_method_policies") + _, err = os.Stat(filepath.Join(root, "deployments")) + require.ErrorIs(t, err, os.ErrNotExist) +} diff --git a/cmd/deploy/secrets.go b/cmd/deploy/secrets.go index 128a61e99..2f204b9a4 100644 --- a/cmd/deploy/secrets.go +++ b/cmd/deploy/secrets.go @@ -89,7 +89,7 @@ restart its consumers. An identifier or signing key is never rotated.`, if err != nil { return err } - if env.ServiceSecrets == nil { + if !env.ServiceSecrets.Stored() { return fmt.Errorf("environment %q declares no service-secrets store", env.Name) } rendered, err := gitops.RenderedServiceSecrets(workspace.Dir(), env.Name) diff --git a/cmd/deploy/service.go b/cmd/deploy/service.go index 3f733044c..0f1703d7b 100644 --- a/cmd/deploy/service.go +++ b/cmd/deploy/service.go @@ -159,6 +159,9 @@ func initDeployService(ctx context.Context, workspace *resources.Workspace, modu evidenceProvider = manager } + if err = gitops.CheckCompositionPermissions(ctx, workspace, env, true, module.Name); err != nil { + return nil, nil, err + } flow, err := orchestration.NewFlow(ctx, workspace, module, service, env, orchestration.DeployMode) if err != nil { return nil, nil, w.Wrap(err) diff --git a/cmd/deploy_gitops_readiness_test.go b/cmd/deploy_gitops_readiness_test.go index 458dbddc2..98602ff38 100644 --- a/cmd/deploy_gitops_readiness_test.go +++ b/cmd/deploy_gitops_readiness_test.go @@ -186,3 +186,26 @@ func TestReadinessCountsRenderOwnedGroupsAsSuppliedOnlyOnLocalK3d(t *testing.T) }) } } + +// The egress group is generated from the environment's egress declaration on +// every run and render, so the doctor never asks a configuration directory +// for it, on any environment. +func TestReadinessCountsTheEgressGroupAsGenerated(t *testing.T) { + dir := writeTestWorkspace(t, map[string]string{ + "workspace.codefly.yaml": "name: demo\nlayout: modules\nmodules:\n - name: backend\nenvironments:\n - name: local\n", + "modules/backend/module.codefly.yaml": testModuleYAML("api"), + "modules/backend/services/api/service.codefly.yaml": testServiceYAML("api", "egress"), + }) + report := runReadiness(t, workspaceReadinessOptions{dir: dir}) + if report.Status != readinessStatusReady { + t.Fatalf("status = %q, want ready\n%s", report.Status, reportJSON(t, report)) + } + requireNoCode(t, report, codeConfigurationMissing) + for _, check := range report.Checks { + if check.Name == "workspace configurations" && check.Status == "ok" && strings.Contains(check.Message, `"egress"`) && + strings.Contains(check.Message, "generated from the environment's egress declaration") && strings.Contains(check.Message, "backend/api") { + return + } + } + t.Fatalf("missing ok report for egress: %s", reportJSON(t, report)) +} diff --git a/cmd/doctor_workspace.go b/cmd/doctor_workspace.go index b10556ece..309efe466 100644 --- a/cmd/doctor_workspace.go +++ b/cmd/doctor_workspace.go @@ -1052,7 +1052,10 @@ func checkConfigurationSources(ctx context.Context, ws *resources.Workspace, env sort.Strings(renderOwned) for _, name := range renderOwned { status, message := "warn", "supplied only by a local k3d or cell render; absent here" - if env.DeclaresLocalK3d() { + switch { + case name == environments.EgressConfiguration: + status, message = "ok", "generated from the environment's egress declaration on every run and render" + case env.DeclaresLocalK3d(): status, message = "ok", "supplied per workload by the render" } report.add("", "workspace configurations", status, diff --git a/cmd/generate.go b/cmd/generate.go index fcba3ed2e..075fe18d1 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -16,6 +16,7 @@ func init() { GenerateCmd.AddCommand(generate.OpenAPICmd) GenerateCmd.AddCommand(generate.ProtoCmd) GenerateCmd.AddCommand(generate.ContractsCmd) + GenerateCmd.AddCommand(generate.PermissionsCmd) GenerateCmd.AddCommand(generate.RunnablesCmd) GenerateCmd.AddCommand(generate.RunnableBindingsCmd) GenerateCmd.AddCommand(generate.ClientCmd) diff --git a/cmd/generate/client.go b/cmd/generate/client.go index cd8a078ae..b05b51d2f 100644 --- a/cmd/generate/client.go +++ b/cmd/generate/client.go @@ -9,6 +9,8 @@ import ( "strings" "time" + "github.com/codefly-dev/core/standards" + "github.com/codefly-dev/cli/cmd/common" "github.com/codefly-dev/cli/pkg/cli" clicomposition "github.com/codefly-dev/cli/pkg/composition" @@ -333,7 +335,7 @@ func resolveLocalSource(ctx context.Context, workspace *resources.Workspace, fro func pickContractEndpoint(ctx context.Context, endpoints []*basev0.Endpoint, hint, moduleName, serviceName string) (*basev0.Endpoint, error) { var candidates []*basev0.Endpoint for _, e := range endpoints { - if resources.IsGRPC(ctx, e) != nil || resources.IsRest(ctx, e) != nil { + if servesContract(ctx, e) { candidates = append(candidates, e) } } @@ -343,10 +345,10 @@ func pickContractEndpoint(ctx context.Context, endpoints []*basev0.Endpoint, hin return e, nil } } - return nil, fmt.Errorf("service %s/%s has no grpc or rest endpoint named %q", moduleName, serviceName, hint) + return nil, fmt.Errorf("service %s/%s has no grpc, connect or rest endpoint named %q", moduleName, serviceName, hint) } if len(candidates) == 0 { - return nil, fmt.Errorf("service %s/%s has no grpc or rest endpoint", moduleName, serviceName) + return nil, fmt.Errorf("service %s/%s has no grpc, connect or rest endpoint", moduleName, serviceName) } if len(candidates) > 1 { var names []string @@ -364,7 +366,12 @@ func pickContractEndpoint(ctx context.Context, endpoints []*basev0.Endpoint, hin // a flat "contract.binpb"/"openapi.json" path (a generated library owns its // own contract/ directory, not a module's committed contracts tree). func buildLocalContractEndpoint(ctx context.Context, service *resources.Service, endpoint *basev0.Endpoint) ([]byte, *composition.APIContractEndpoint, []coreproto.Source, error) { - if grpc := resources.IsGRPC(ctx, endpoint); grpc != nil { + // A Connect endpoint serves the service's protobuf contract over the + // Connect protocol family; its contract is the gRPC one. + if grpc := resources.IsGRPC(ctx, endpoint); grpc != nil || endpoint.GetApi() == standards.CONNECT { + if grpc == nil { + grpc = &basev0.GrpcAPI{} + } protoDir := filepath.Join(service.Dir(), "proto") if ok, _ := shared.FileExists(ctx, filepath.Join(protoDir, "buf.yaml")); !ok { return nil, nil, nil, fmt.Errorf("service %s has no proto/buf.yaml; cannot build a descriptor set for endpoint %s", service.Name, endpoint.Name) @@ -381,15 +388,24 @@ func buildLocalContractEndpoint(ctx context.Context, service *resources.Service, if err != nil { return nil, nil, nil, fmt.Errorf("cannot read proto sources: %w", err) } + // The contract's package is the one the service's own protos declare + // their services in, as `generate contracts` settles it; an endpoint + // declaring none in service.codefly.yaml is the common case. + pkg := grpc.Package + if pkg == "" { + if pkg, err = serviceContractPackage(&set, protoDir, nil); err != nil { + return nil, nil, nil, fmt.Errorf("service %s endpoint %s: %w", service.Name, endpoint.Name, err) + } + } return descriptorSet, &composition.APIContractEndpoint{ Service: service.Name, Endpoint: endpoint.Name, API: endpoint.Api, Kind: composition.APIContractKindProtobuf, - Package: grpc.Package, + Package: pkg, Path: "contract.binpb", Digest: composition.APIContractDigest(descriptorSet), - Services: composition.ProtobufServices(&set, grpc.Package), + Services: composition.ProtobufServices(&set, pkg), }, protoSources, nil } if rest := resources.IsRest(ctx, endpoint); rest != nil { @@ -400,12 +416,7 @@ func buildLocalContractEndpoint(ctx context.Context, service *resources.Service, if err != nil { return nil, nil, nil, fmt.Errorf("cannot read OpenAPI info.title: %w", err) } - var routes []composition.APIContractRoute - for _, group := range rest.Groups { - for _, route := range group.Routes { - routes = append(routes, composition.APIContractRoute{Method: route.Method.String(), Path: filepath.ToSlash(filepath.Join(group.Path, route.Path))}) - } - } + routes := contractRoutes(rest.Groups) return rest.Openapi, &composition.APIContractEndpoint{ Service: service.Name, Endpoint: endpoint.Name, @@ -602,3 +613,10 @@ func lastPathSegment(s string) string { parts := strings.Split(s, "/") return parts[len(parts)-1] } + +// servesContract is whether an endpoint has a contract a client is generated +// from: a protobuf one (grpc, or connect, which serves the same proto over the +// Connect protocol family) or an OpenAPI one (rest). +func servesContract(ctx context.Context, e *basev0.Endpoint) bool { + return resources.IsGRPC(ctx, e) != nil || resources.IsRest(ctx, e) != nil || e.GetApi() == standards.CONNECT +} diff --git a/cmd/generate/client_test.go b/cmd/generate/client_test.go index 83a882bb0..10847bbcb 100644 --- a/cmd/generate/client_test.go +++ b/cmd/generate/client_test.go @@ -5,6 +5,8 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/standards" "io" "os" "os/exec" @@ -866,3 +868,25 @@ func writeContractsCatalog(t *testing.T, moduleDir, packageID string, contractBy } return contractsDir } + +// A connect endpoint serves the protobuf contract too, so --from +// module/service/connect selects it. +func TestPickContractEndpointAcceptsConnect(t *testing.T) { + ctx := context.Background() + connect := &basev0.Endpoint{Name: "connect", Api: standards.CONNECT} + tcp := &basev0.Endpoint{Name: "tcp", Api: standards.TCP} + e, err := pickContractEndpoint(ctx, []*basev0.Endpoint{connect, tcp}, "connect", "evaluation", "registry") + if err != nil || e.Name != "connect" { + t.Fatalf("connect by name: %v %v", e, err) + } + if e, err := pickContractEndpoint(ctx, []*basev0.Endpoint{connect, tcp}, "", "evaluation", "registry"); err != nil || e.Name != "connect" { + t.Fatalf("the one contract endpoint is picked without a hint: %v %v", e, err) + } + two := []*basev0.Endpoint{connect, {Name: "connect-admin", Api: standards.CONNECT}} + if _, err := pickContractEndpoint(ctx, two, "", "evaluation", "registry"); err == nil || !strings.Contains(err.Error(), "multiple") { + t.Fatalf("two contract endpoints need a hint: %v", err) + } + if _, err := pickContractEndpoint(ctx, []*basev0.Endpoint{connect, tcp}, "tcp", "evaluation", "registry"); err == nil { + t.Fatal("tcp serves no contract") + } +} diff --git a/cmd/generate/contract_surface.go b/cmd/generate/contract_surface.go index 9555bdef3..8ec795e24 100644 --- a/cmd/generate/contract_surface.go +++ b/cmd/generate/contract_surface.go @@ -8,7 +8,6 @@ import ( "io" "os" "path/filepath" - "sort" "github.com/codefly-dev/core/composition" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" @@ -116,39 +115,9 @@ func loadContractSurfaces(service *resources.Service, endpoints []*basev0.Endpoi // descriptor. A descriptor supplies schemas and options, including transitive // imports; the catalog says which of those methods this listener serves. func endpointProcedures(set *descriptorpb.FileDescriptorSet, endpoint *composition.APIContractEndpoint) ([]string, error) { - available := map[string]map[string]bool{} - names := map[string]string{} - for _, service := range composition.ProtobufServices(set, endpoint.Package) { - methods := map[string]bool{} - for _, method := range service.Procedures { - methods[method] = true - } - available[service.FullName] = methods - names[service.FullName] = service.Name - } - seen := map[string]bool{} - seenServices := map[string]bool{} - var methods []string - for _, service := range endpoint.Services { - declared, exists := available[service.FullName] - if !exists { - return nil, fmt.Errorf("endpoint %s/%s: service %s is absent from its descriptor package %s", endpoint.Service, endpoint.Endpoint, service.FullName, endpoint.Package) - } - if service.Name != names[service.FullName] || seenServices[service.FullName] { - return nil, fmt.Errorf("endpoint %s/%s: service %s must use descriptor name %s and appear exactly once", endpoint.Service, endpoint.Endpoint, service.FullName, names[service.FullName]) - } - seenServices[service.FullName] = true - for _, method := range service.Procedures { - if !declared[method] { - return nil, fmt.Errorf("endpoint %s/%s: procedure %s is absent from its descriptor service %s", endpoint.Service, endpoint.Endpoint, method, service.FullName) - } - if seen[method] { - return nil, fmt.Errorf("endpoint %s/%s declares procedure twice: %s", endpoint.Service, endpoint.Endpoint, method) - } - seen[method] = true - methods = append(methods, method) - } + methods, err := composition.ProtobufProcedures(set, endpoint.Services) + if err != nil { + return nil, fmt.Errorf("endpoint %s/%s: %w", endpoint.Service, endpoint.Endpoint, err) } - sort.Strings(methods) return methods, nil } diff --git a/cmd/generate/contract_surface_test.go b/cmd/generate/contract_surface_test.go index 900b35ed8..fbe95c86e 100644 --- a/cmd/generate/contract_surface_test.go +++ b/cmd/generate/contract_surface_test.go @@ -2,8 +2,12 @@ package generate import ( "context" + "fmt" + googleproto "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" "os" "path/filepath" + "strings" "testing" "github.com/codefly-dev/core/composition" @@ -135,3 +139,69 @@ func TestContractSurfaceConfigurationIsCLIOwnedAndStrict(t *testing.T) { _, err := loadContractSurfaces(service, []*basev0.Endpoint{{Name: "grpc", Api: "grpc"}, {Name: "connect", Api: "connect"}}, "") require.ErrorContains(t, err, contractSurfacesConfigFile, "agent settings are not CLI configuration") } + +// An implementing listener serves its own API and several host interfaces. Its +// generated inventory, rather than a proto package filter, defines publication. +func TestContractSurfacePublishesImplementedInterfacePackages(t *testing.T) { + ctx := context.Background() + service := &resources.Service{Name: "accounts"} + module := &resources.Module{Name: "saas"} + module.WithDir(t.TempDir()) + service.WithDir(filepath.Join(module.Dir(), "services/accounts")) + var files []*descriptorpb.FileDescriptorProto + var surface []composition.APIContractService + for _, entry := range []struct { + pkg, name string + count int + }{ + {"saas.accounts.v1", "Accounts", 1}, + {"host.authority.v1", "AuthorityService", 7}, + {"host.installation.v1", "InstallationService", 3}, + {"host.session.v1", "SessionService", 3}, + {"host.composition.v1", "CompositionService", 1}, + {"foreign.client.v1", "NotServed", 1}, + } { + f := &descriptorpb.FileDescriptorProto{Name: googleproto.String(strings.ReplaceAll(entry.pkg, ".", "/") + "/api.proto"), Package: googleproto.String(entry.pkg), + Service: []*descriptorpb.ServiceDescriptorProto{{Name: googleproto.String(entry.name)}}, + } + for i := 0; i < entry.count; i++ { + f.Service[0].Method = append(f.Service[0].Method, &descriptorpb.MethodDescriptorProto{Name: googleproto.String(fmt.Sprintf("Call%d", i))}) + } + files = append(files, f) + // Implemented schemas can be imports rather than physically owned files. + if strings.HasPrefix(entry.pkg, "saas.") || strings.HasPrefix(entry.pkg, "foreign.") { + writeFixtureFile(t, filepath.Join(service.Dir(), "proto", f.GetName()), []byte("// source snapshot\n")) + } + if entry.name != "NotServed" { + surface = append(surface, composition.ProtobufServices(&descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{f}}, entry.pkg)...) + } + } + set := &descriptorpb.FileDescriptorSet{File: files} + data, err := googleproto.Marshal(set) + require.NoError(t, err) + dir := filepath.Join(module.Dir(), "contracts/api/accounts/connect") + require.NoError(t, os.MkdirAll(dir, 0o755)) + endpoint := &basev0.Endpoint{Name: "connect", Api: "connect"} + entry, err := writeBuiltProtobufContract(ctx, module, service, endpoint, surface, dir, dir, data) + require.NoError(t, err) + require.Empty(t, entry.Package, "there is no single package; fullName carries each service's package") + require.Equal(t, surface, entry.Services) + methods, err := endpointProcedures(set, entry) + require.NoError(t, err) + require.Len(t, methods, 15, "own method plus all 14 implemented host procedures") + for _, method := range methods { + require.NotContains(t, method, "NotServed") + } + catalog := &composition.APIContractCatalog{Schema: composition.APIContractCatalogSchema, Package: "codefly/saas", Version: "0.1.0", Endpoints: []composition.APIContractEndpoint{*entry}} + require.NoError(t, catalog.Validate()) + encoded, err := catalog.CanonicalBytes() + require.NoError(t, err) + writeFixtureFile(t, filepath.Join(module.Dir(), composition.APIContractCatalogFileName), encoded) + loaded, err := composition.LoadAPIContractCatalog(module.Dir()) + require.NoError(t, err) + require.Len(t, loaded.Endpoints[0].Services, 5) + // A typo or a method missing from the descriptor must still be a named refusal. + entry.Services[1].Procedures = append(entry.Services[1].Procedures, "/host.authority.v1.AuthorityService/Missing") + _, err = endpointProcedures(set, entry) + require.ErrorContains(t, err, "/host.authority.v1.AuthorityService/Missing") +} diff --git a/cmd/generate/contracts.go b/cmd/generate/contracts.go index 0f6b4cdb5..5eb8c5cc9 100644 --- a/cmd/generate/contracts.go +++ b/cmd/generate/contracts.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "os" - "path" "path/filepath" "sort" "strings" @@ -30,9 +29,10 @@ import ( ) var ( - contractsOutput string - contractsCheck bool - contractsFormat string + contractsOutput string + contractsCheck bool + contractsPermissions bool + contractsFormat string ) // yamlStringTag is the yaml.v3 tag for a plain string scalar node. @@ -49,14 +49,21 @@ module package carries the contract. gRPC and connect endpoints get a serialized FileDescriptorSet (contract.binpb) plus a copy of the service's proto sources; REST endpoints get their OpenAPI -document (openapi.json). HTTP, TCP, and MCP endpoints have no machine-readable -contract and are skipped, as is a connect endpoint whose service has no proto. +document (openapi.json). An HTTP endpoint gets the OpenAPI document its service +carries (openapi/api.swagger.json), when it carries one: a solution backend +publishes its routes there, each with its x-codefly-authority-policy. TCP and +MCP endpoints have no machine-readable contract and are skipped, as are an HTTP +endpoint whose service carries no document and a connect endpoint whose +service has no proto. For restricted listeners, module contracts.codefly.yaml maps service names under surfaces to service-relative JSON procedure inventories. This is CLI publication configuration, separate from the agent's spec. Multiple exported protobuf endpoints require an inventory; a full descriptor alone does not establish what -each listener serves. See docs/commands.md#generate-contracts. +each listener serves. An inventory may include implemented interfaces from other +protobuf packages; every named procedure must exist in the descriptor. For such +multi-package endpoints, package is empty and services[].fullName identifies +each package. See docs/commands.md#generate-contracts. Run it before module-package build; the package carries the result. --check is the CI drift gate: it regenerates into a temporary directory and compares @@ -96,7 +103,13 @@ against what's on disk, without writing anything. } if contractsCheck { - return runContractsCheck(ctx, workspace, module, output) + if err = runContractsCheck(ctx, workspace, module, output); err != nil { + return err + } + if contractsPermissions { + return generatePermissions(ctx, module, "", true) + } + return nil } catalog, err := generateContracts(ctx, workspace, module, generateOptions{ @@ -108,6 +121,11 @@ against what's on disk, without writing anything. return err } + if contractsPermissions { + if err = generatePermissions(ctx, module, "", false); err != nil { + return err + } + } if contractsFormat == "json" { data, err := catalog.CanonicalBytes() if err != nil { @@ -121,6 +139,7 @@ against what's on disk, without writing anything. } func init() { + ContractsCmd.Flags().BoolVar(&contractsPermissions, "permissions", false, "also generate typed permission constants; with --check, verify their freshness") ContractsCmd.Flags().StringVar(&contractsOutput, "output", "", "output directory for generated contracts (default: /contracts/api)") ContractsCmd.Flags().BoolVar(&contractsCheck, "check", false, "do not write; exit 1 if the on-disk catalog differs from what would be generated") ContractsCmd.Flags().StringVar(&contractsFormat, "format", "text", "output format: text|json") @@ -186,8 +205,15 @@ func generateContracts(ctx context.Context, workspace *resources.Workspace, modu // always carries a contract, so a missing proto there stays a hard // error (surfaced by writeProtobufContract). serviceHasProto, _ := shared.FileExists(ctx, filepath.Join(service.Dir(), "proto", "buf.yaml")) - - carriers := contractCarriers(ctx, serviceName, exported, serviceHasProto) + // A rest endpoint's contract is the OpenAPI document its service carries + // where core loads it (standards.OpenAPIPath). The exported endpoint is + // read from the module's interface and carries no API details, so it + // cannot answer this; asking it skipped every REST face. An http + // endpoint's is the same document, when its service carries one: a + // solution backend serves plain HTTP and publishes its routes there. + serviceHasOpenAPI, _ := shared.FileExists(ctx, filepath.Join(service.Dir(), standards.OpenAPIPath)) + + carriers := contractCarriers(ctx, serviceName, exported, serviceHasProto, serviceHasOpenAPI) if len(carriers) == 0 { continue } @@ -196,17 +222,29 @@ func generateContracts(ctx context.Context, workspace *resources.Workspace, modu return nil, fmt.Errorf("module %s: %w", module.Name, surfaceErr) } - loaded, loadErr := generators.LoadServiceEndpoints(ctx, workspace, module, service) - if loadErr != nil { - return nil, fmt.Errorf("cannot load endpoints for service %s: %w", serviceName, loadErr) - } - loadedByName := make(map[string]*basev0.Endpoint, len(loaded)) - for _, endpoint := range loaded { - loadedByName[endpoint.Name] = endpoint + // The agent reports each endpoint's API details at Load. An http + // endpoint has none to report (its document is the service's file), so + // a service whose carriers are all http is not loaded. + var loadedByName map[string]*basev0.Endpoint + for _, endpoint := range carriers { + if endpoint.Api == standards.HTTP || loadedByName != nil { + continue + } + loaded, loadErr := generators.LoadServiceEndpoints(ctx, workspace, module, service) + if loadErr != nil { + return nil, fmt.Errorf("cannot load endpoints for service %s: %w", serviceName, loadErr) + } + loadedByName = make(map[string]*basev0.Endpoint, len(loaded)) + for _, endpoint := range loaded { + loadedByName[endpoint.Name] = endpoint + } } for _, endpoint := range carriers { - actual, ok := loadedByName[endpoint.Name] + actual, ok := endpoint, true + if endpoint.Api != standards.HTTP { + actual, ok = loadedByName[endpoint.Name] + } if !ok { return nil, fmt.Errorf("service %s does not report endpoint %q at Builder.Load", serviceName, endpoint.Name) } @@ -248,7 +286,7 @@ func anyCarriesContract(exported []*basev0.Endpoint) bool { // contractCarriers keeps the exported endpoints that carry a machine-readable // contract, saying why each of the others is skipped. -func contractCarriers(ctx context.Context, serviceName string, exported []*basev0.Endpoint, serviceHasProto bool) []*basev0.Endpoint { +func contractCarriers(_ context.Context, serviceName string, exported []*basev0.Endpoint, serviceHasProto, serviceHasOpenAPI bool) []*basev0.Endpoint { var carriers []*basev0.Endpoint for _, endpoint := range exported { switch { @@ -256,8 +294,10 @@ func contractCarriers(ctx context.Context, serviceName string, exported []*basev cli.Info("endpoint %s/%s (api %s) has no contract; skipped", serviceName, endpoint.Name, endpoint.Api) case endpoint.Api == standards.CONNECT && !serviceHasProto: cli.Info("endpoint %s/%s (api connect) has no proto; skipped", serviceName, endpoint.Name) - case endpoint.Api == standards.REST && restWithoutOpenAPI(ctx, endpoint): + case endpoint.Api == standards.REST && !serviceHasOpenAPI: cli.Info("endpoint %s/%s (api rest) has no OpenAPI document; exported for reachability only, skipped", serviceName, endpoint.Name) + case endpoint.Api == standards.HTTP && !serviceHasOpenAPI: + cli.Info("endpoint %s/%s (api http) has no OpenAPI document; skipped", serviceName, endpoint.Name) default: carriers = append(carriers, endpoint) } @@ -282,6 +322,11 @@ func writeContractCatalog(ctx context.Context, module *resources.Module, contrac if err = catalog.Validate(); err != nil { return nil, fmt.Errorf("generated catalog is invalid: %w", err) } + if opts.updateManifest && manifest != nil { + if err = requireContractArtifactRoots(module, manifest, contractEndpoints); err != nil { + return nil, err + } + } catalogBytes, err := catalog.CanonicalBytes() if err != nil { @@ -303,6 +348,31 @@ func writeContractCatalog(ctx context.Context, module *resources.Module, contrac return catalog, nil } +// requireContractArtifactRoots refuses, before the catalog or the package +// manifest is written, a manifest whose artifact-roots do not cover the +// contract files this run records in it: the package would not carry them, +// and composition.ValidatePackageAPIContracts would refuse the manifest only +// after it had been rewritten. The refusal names the manifest and the root to +// add — the top directory of the first uncovered contract path. +func requireContractArtifactRoots(module *resources.Module, manifest *composition.PackageManifest, contractEndpoints []composition.APIContractEndpoint) error { + for i := range contractEndpoints { + endpoint := &contractEndpoints[i] + covered := false + for _, root := range manifest.ArtifactRoots { + if endpoint.Path == root || strings.HasPrefix(endpoint.Path, root+"/") { + covered = true + break + } + } + if !covered { + root, _, _ := strings.Cut(endpoint.Path, "/") + return fmt.Errorf("%w: package manifest %s does not declare the artifact root %q, so the package cannot carry the %s/%s contract at %s; add %q under artifact-roots", + composition.ErrContract, filepath.Join(module.Dir(), composition.PackageManifestFileName), root, endpoint.Service, endpoint.Endpoint, endpoint.Path, root) + } + } + return nil +} + // resolvePackageIdentity returns the catalog's Package/Version and, when the // module has one, its package manifest. A module without a manifest is not // packageable yet, so it falls back to a synthetic identity and a warning @@ -319,19 +389,12 @@ func resolvePackageIdentity(module *resources.Module) (string, string, *composit return manifest.ID, manifest.Version, manifest, nil } -// restWithoutOpenAPI reports whether a rest endpoint carries no OpenAPI -// document to export: either core attached no REST details to it or the -// document is empty. -func restWithoutOpenAPI(ctx context.Context, endpoint *basev0.Endpoint) bool { - rest := resources.IsRest(ctx, endpoint) - return rest == nil || len(rest.Openapi) == 0 -} - // endpointCarriesContract reports whether an endpoint's API can be exported as // a machine-readable contract: grpc and connect resolve to a protobuf -// FileDescriptorSet, rest to an OpenAPI document. http and tcp have none. +// FileDescriptorSet, rest and http to an OpenAPI document (http only when its +// service carries one, which contractCarriers decides). tcp and mcp have none. func endpointCarriesContract(api string) bool { - return api == standards.GRPC || api == standards.CONNECT || api == standards.REST + return api == standards.GRPC || api == standards.CONNECT || api == standards.REST || api == standards.HTTP } // writeEndpointContract writes one endpoint's contract files and returns its @@ -352,6 +415,17 @@ func writeEndpointContract(ctx context.Context, module *resources.Module, servic if rest := resources.IsRest(ctx, endpoint); rest != nil { return writeRestContract(ctx, module, endpoint, rest, physicalDir, canonicalDir) } + if endpoint.Api == standards.HTTP { + // An http endpoint reports no API details at Load, so its document is + // read where the service carries it, by core's own REST loader: the + // same bytes and routes a rest endpoint's agent reports. + document := filepath.Join(service.Dir(), standards.OpenAPIPath) + rest, err := resources.LoadRestAPI(ctx, &document) + if err != nil { + return nil, fmt.Errorf("endpoint %s: %s: %w", endpoint.Name, standards.OpenAPIPath, err) + } + return writeRestContract(ctx, module, endpoint, rest, physicalDir, canonicalDir) + } return nil, fmt.Errorf("endpoint %s reports neither a grpc/connect nor a rest contract", endpoint.Name) } @@ -417,24 +491,26 @@ func writeBuiltProtobufContract(ctx context.Context, module *resources.Module, s return entry, nil } -// serviceContractPackage returns the single proto package that the service's -// own proto files — those physically under protoDir, not the transitive -// imports buf pulls in — use to declare services. A codefly service declares -// all of its RPC services in one package; message-only packages contributed -// alongside it (composed settings, shared types) carry no service to expose in -// a client contract and are ignored. -// -// `surface` is the endpoint's declared served surface, when the module declares -// one through contracts.codefly.yaml. It names which services this endpoint -// serves, so it also settles which proto package the contract covers, and the -// candidates are scoped to it. That matters for a service that must generate -// client stubs from ANOTHER service's API: those protos declare services in a -// foreign package and have to live in the owned tree for codegen to reach them, -// yet the endpoint does not serve them, so without this they make a -// single-package endpoint look multi-package and the export fails on a -// contract that is in fact well formed. A module that declares no surface is -// unchanged: nothing narrows the candidates and the single-package rule stands. +// serviceContractPackage preserves the single-package default. An explicit +// listener inventory may span packages; then the package field is empty and +// each service's fully qualified name supplies its package. func serviceContractPackage(set *descriptorpb.FileDescriptorSet, protoDir string, surface []composition.APIContractService) (string, error) { + if packages := surfacePackages(surface); len(packages) > 0 { + names := make([]string, 0, len(packages)) + for pkg := range packages { + names = append(names, pkg) + } + sort.Strings(names) + for _, pkg := range names { + if len(composition.ProtobufServices(set, pkg)) == 0 { + return "", fmt.Errorf("inventory package %s declares no services in the descriptor", pkg) + } + } + if len(names) == 1 { + return names[0], nil + } + return "", nil + } own := map[string]struct{}{} walkErr := filepath.WalkDir(protoDir, func(p string, entry os.DirEntry, err error) error { if err != nil { @@ -474,23 +550,6 @@ func serviceContractPackage(set *descriptorpb.FileDescriptorSet, protoDir string } sort.Strings(packages) - // Scope to the declared surface. Dropping to zero means the surface names - // no package the descriptor set declares a service in, which is a - // configuration error the unscoped diagnostics below describe better than a - // silent empty contract, so the narrowing is only applied when it keeps at - // least one candidate. - if served := surfacePackages(surface); len(served) > 0 { - scoped := make([]string, 0, len(packages)) - for _, pkg := range packages { - if _, ok := served[pkg]; ok { - scoped = append(scoped, pkg) - } - } - if len(scoped) > 0 { - packages = scoped - } - } - switch len(packages) { case 0: // buf always includes the service's own files in the descriptor set, @@ -539,15 +598,7 @@ func writeRestContract(ctx context.Context, module *resources.Module, endpoint * return nil, fmt.Errorf("cannot read OpenAPI info.title: %w", err) } - var routes []composition.APIContractRoute - for _, group := range rest.Groups { - for _, route := range group.Routes { - routes = append(routes, composition.APIContractRoute{ - Method: route.Method.String(), - Path: path.Join(group.Path, route.Path), - }) - } - } + routes := contractRoutes(rest.Groups) relPath, err := relativeContractPath(module, filepath.Join(canonicalDir, "openapi.json")) if err != nil { @@ -717,7 +768,7 @@ func runContractsCheck(ctx context.Context, workspace *resources.Workspace, modu return err } - diffs, err := diffTrees(tempDir, output) + diffs, err := generators.DiffTrees(tempDir, output) if err != nil { return err } @@ -732,71 +783,6 @@ func runContractsCheck(ctx context.Context, workspace *resources.Workspace, modu return fmt.Errorf("generated contracts differ from %s (%d file(s)); run `codefly generate contracts` to update", output, len(diffs)) } -// diffTrees returns the slash-separated, sorted set of relative paths whose -// content (or presence) differs between the two directory trees. -func diffTrees(generated, existing string) ([]string, error) { - generatedFiles, err := listRelativeFiles(generated) - if err != nil { - return nil, err - } - existingFiles, err := listRelativeFiles(existing) - if err != nil { - return nil, err - } - - seen := map[string]struct{}{} - for f := range generatedFiles { - seen[f] = struct{}{} - } - for f := range existingFiles { - seen[f] = struct{}{} - } - - var diffs []string - for f := range seen { - a, okA := generatedFiles[f] - b, okB := existingFiles[f] - if okA != okB || !bytes.Equal(a, b) { - diffs = append(diffs, f) - } - } - sort.Strings(diffs) - return diffs, nil -} - -func listRelativeFiles(root string) (map[string][]byte, error) { - files := map[string][]byte{} - info, err := os.Stat(root) - if err != nil { - if os.IsNotExist(err) { - return files, nil - } - return nil, err - } - if !info.IsDir() { - return nil, fmt.Errorf("%s is not a directory", root) - } - err = filepath.WalkDir(root, func(p string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() { - return nil - } - rel, relErr := filepath.Rel(root, p) - if relErr != nil { - return relErr - } - data, readErr := os.ReadFile(p) //nolint:gosec // p is walked from root, a generated-contracts directory this command owns - if readErr != nil { - return readErr - } - files[filepath.ToSlash(rel)] = data - return nil - }) - return files, err -} - // updateManifestFile rewrites module.package.codefly.yaml's // services[*].api-contracts for the services present in contractEndpoints, // preserving every other byte of the file (comments, key order, unrelated @@ -935,3 +921,17 @@ func appendScalarField(node *yaml.Node, key, value string) { &yaml.Node{Kind: yaml.ScalarNode, Tag: yamlStringTag, Value: value}, ) } + +// contractRoutes lists a REST endpoint's routes for its contract entry. A +// route's path is already the full path (core's loader holds every route to +// start with its group's path), so the group's path is never prepended: doing +// so wrote "/v1/x/y/v1/x/y" into every catalog. +func contractRoutes(groups []*basev0.RestRouteGroup) []composition.APIContractRoute { + var routes []composition.APIContractRoute + for _, group := range groups { + for _, route := range group.Routes { + routes = append(routes, composition.APIContractRoute{Method: route.Method.String(), Path: route.Path}) + } + } + return routes +} diff --git a/cmd/generate/contracts_test.go b/cmd/generate/contracts_test.go index db00a3144..79f79d279 100644 --- a/cmd/generate/contracts_test.go +++ b/cmd/generate/contracts_test.go @@ -3,6 +3,9 @@ package generate import ( "context" "encoding/json" + "errors" + "fmt" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "io" "os" "os/exec" @@ -11,6 +14,7 @@ import ( "testing" "github.com/codefly-dev/cli/pkg/conformance/conformancetest" + "github.com/codefly-dev/cli/pkg/gitops" "github.com/codefly-dev/core/composition" "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/services" @@ -206,7 +210,7 @@ func TestGenerateContractsSkipsNonContractEndpoints(t *testing.T) { if err != nil { t.Fatalf("RunE: %v\noutput:\n%s", err, out) } - if !strings.Contains(out, "no contract; skipped") { + if !strings.Contains(out, "api http) has no OpenAPI document; skipped") { t.Fatalf("output missing skip notice:\n%s", out) } @@ -265,6 +269,70 @@ func TestGenerateContractsSkipsConnectWithoutProto(t *testing.T) { } } +// A package manifest without the contracts artifact root cannot carry what +// generate contracts writes. The refusal is returned, not logged, and names +// the manifest and the root to add; the manifest is left as it was. +func TestGenerateContractsNamesTheMissingArtifactRoot(t *testing.T) { + ctx := context.Background() + root, moduleDir := saveFixtureWorkspace(t, ctx, "billing", + &resources.ModuleInterface{ + Endpoints: []*resources.InterfaceEndpoint{ + {Service: "api", Endpoint: "http", Visibility: resources.VisibilityPublic}, + }, + }, + &resources.Service{ + Name: "api", + Version: "0.0.1", + Endpoints: []*resources.Endpoint{ + {Name: "http", API: "http", Visibility: resources.VisibilityPublic, Exposure: resources.ExposureNone}, + }, + }, + ) + serviceDir := filepath.Join(moduleDir, "services", "api") + if err := os.MkdirAll(filepath.Join(serviceDir, filepath.Dir(standards.OpenAPIPath)), 0o755); err != nil { + t.Fatal(err) + } + document := `{"swagger":"2.0","info":{"title":"billing backend","version":"0.0.1"},"paths":{"/v1/billing/pages":{"get":{"x-codefly-authority-policy":{"any_checked_caller":true}}}}}` + if err := os.WriteFile(filepath.Join(serviceDir, standards.OpenAPIPath), []byte(document), 0o644); err != nil { + t.Fatal(err) + } + manifest := `schema: codefly/module-package/v2 +kind: module-package +id: codefly/billing +version: 0.1.0 +minimum-codefly-version: ">=0.0.0" +artifact-roots: + - services +contracts: + composition: ">=2.0.0 <3.0.0" +services: + - name: api + endpoints: [http] +` + manifestPath := filepath.Join(moduleDir, composition.PackageManifestFileName) + if err := os.WriteFile(manifestPath, []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + + t.Chdir(root) + resetContractsFlags(t) + _, err := captureStdout(t, func() error { return ContractsCmd.RunE(ContractsCmd, []string{"billing"}) }) + if err == nil { + t.Fatal("expected a refusal for a manifest without the contracts artifact root") + } + if !errors.Is(err, composition.ErrContract) { + t.Fatalf("error %v does not wrap composition.ErrContract", err) + } + for _, want := range []string{manifestPath, `artifact root "contracts"`, "contracts/api/api/http/openapi.json"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not name %q", err, want) + } + } + if got, readErr := os.ReadFile(manifestPath); readErr != nil || string(got) != manifest { + t.Errorf("manifest changed by a refused run (err=%v):\n%s", readErr, got) + } +} + // The tests below scaffold a real go-grpc service and run buf inside the // proto companion, so they need Docker and network access. Gated like the // other agent-backed qualification tests (see docs/commands.md @@ -311,6 +379,97 @@ func TestGenerateContractsSkipsRestWithoutOpenAPI(t *testing.T) { } } +// A solution backend serves plain HTTP and publishes its routes, each with its +// x-codefly-authority-policy, in the OpenAPI document its service carries. That +// document is the http endpoint's contract, exported as a rest endpoint's is, +// so the package names it and the solution's route policy rows are derived +// from it the same way: " " as served, with the declared policy. +// No agent is loaded, since an http endpoint reports no API details at Load. +func TestGenerateContractsPublishesAnHTTPEndpointsOpenAPIDocument(t *testing.T) { + ctx := context.Background() + root, moduleDir := saveFixtureWorkspace(t, ctx, "billing", + &resources.ModuleInterface{ + Endpoints: []*resources.InterfaceEndpoint{ + {Service: "api", Endpoint: "http", Visibility: resources.VisibilityPublic}, + }, + }, + &resources.Service{ + Name: "api", + Version: "0.0.1", + Endpoints: []*resources.Endpoint{ + {Name: "http", API: "http", Visibility: resources.VisibilityPublic, Exposure: resources.ExposureNone}, + }, + }, + ) + document := `{"swagger":"2.0","info":{"title":"billing backend","version":"0.0.1"},"paths":{ + "/v1/billing/upload-access":{"get":{"x-codefly-authority-policy":{"requires":[{"resource_kind":"billing.page","actions":["read"]}]}}}, + "/v1/billing/modules/robin/{path=**}":{"post":{"x-codefly-authority-policy":{"any_checked_caller":true}}}}}` + serviceDir := filepath.Join(moduleDir, "services", "api") + if err := os.MkdirAll(filepath.Join(serviceDir, filepath.Dir(standards.OpenAPIPath)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(serviceDir, standards.OpenAPIPath), []byte(document), 0o644); err != nil { + t.Fatal(err) + } + manifest := `schema: codefly/module-package/v2 +kind: module-package +id: codefly/billing +version: 0.1.0 +minimum-codefly-version: ">=0.0.0" +artifact-roots: + - contracts + - services +contracts: + composition: ">=2.0.0 <3.0.0" +services: + - name: api + endpoints: [http] +` + if err := os.WriteFile(filepath.Join(moduleDir, composition.PackageManifestFileName), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + + t.Chdir(root) + resetContractsFlags(t) + if out, err := captureStdout(t, func() error { return ContractsCmd.RunE(ContractsCmd, []string{"billing"}) }); err != nil { + t.Fatalf("RunE: %v\noutput:\n%s", err, out) + } + + catalog, err := composition.LoadAPIContractCatalog(moduleDir) + if err != nil { + t.Fatal(err) + } + if len(catalog.Endpoints) != 1 { + t.Fatalf("catalog.Endpoints = %+v, want the http endpoint", catalog.Endpoints) + } + entry := catalog.Endpoints[0] + if entry.API != standards.HTTP || entry.Kind != composition.APIContractKindOpenAPI || entry.Package != "billing backend" || + entry.Path != "contracts/api/api/http/openapi.json" || entry.Digest != composition.APIContractDigest([]byte(document)) || len(entry.Routes) != 2 { + t.Fatalf("catalog entry = %+v", entry) + } + + methods, ok, err := gitops.MethodPolicies(moduleDir) + if err != nil || !ok { + t.Fatalf("MethodPolicies: ok=%v err=%v", ok, err) + } + got := map[string]string{} + for _, m := range methods { + got[m.GetMethod()] = fmt.Sprintf("any=%v requires=%v", m.GetAnyCheckedCaller(), m.GetRequires()) + } + want := map[string]string{ + "GET /v1/billing/upload-access": `any=false requires=[resource_kind:"billing.page" actions:"read"]`, + "POST /v1/billing/modules/robin/{path=**}": `any=true requires=[]`, + } + if len(got) != len(want) { + t.Fatalf("route rows = %v, want %v", got, want) + } + for method, policy := range want { + if strings.Join(strings.Fields(got[method]), " ") != policy { + t.Fatalf("row %s = %q, want %q (all rows %v)", method, got[method], policy, got) + } + } +} + // The tests below scaffold a real go-grpc service and run buf inside the // proto companion, so they need Docker and network access. Gated like the // other agent-backed qualification tests (see docs/commands.md @@ -527,12 +686,12 @@ func TestGenerateContractsCheckDetectsDrift(t *testing.T) { } func TestEndpointCarriesContract(t *testing.T) { - for _, api := range []string{standards.GRPC, standards.CONNECT, standards.REST} { + for _, api := range []string{standards.GRPC, standards.CONNECT, standards.REST, standards.HTTP} { if !endpointCarriesContract(api) { t.Errorf("endpointCarriesContract(%q) = false, want true", api) } } - for _, api := range []string{standards.HTTP, standards.TCP} { + for _, api := range []string{standards.TCP, standards.MCP} { if endpointCarriesContract(api) { t.Errorf("endpointCarriesContract(%q) = true, want false", api) } @@ -633,7 +792,7 @@ func TestServiceContractPackage(t *testing.T) { } }) - t.Run("a surface naming no declared package falls back to the unscoped diagnostics", func(t *testing.T) { + t.Run("a surface naming no declared package is refused by name", func(t *testing.T) { set := &descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{ file("saas/acct/v1/api.proto", "saas.acct.v1", true), file("saas/shared/v1/types.proto", "saas.shared.v1", true), @@ -641,8 +800,19 @@ func TestServiceContractPackage(t *testing.T) { surface := []composition.APIContractService{ {Name: "Absent", FullName: "saas.absent.v1.Absent"}, } - if _, err := serviceContractPackage(set, protoDir, surface); err == nil { - t.Fatal("expected the multi-package refusal rather than a silent empty contract") + if _, err := serviceContractPackage(set, protoDir, surface); err == nil || !strings.Contains(err.Error(), "saas.absent.v1") { + t.Fatalf("expected the absent inventory package to be named: %v", err) + } + }) + + t.Run("a listener may exclusively implement an imported package", func(t *testing.T) { + set := &descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{ + file("saas/acct/v1/api.proto", "saas.acct.v1", false), + file("host/authority/v1/api.proto", "host.authority.v1", true), + }} + pkg, err := serviceContractPackage(set, protoDir, []composition.APIContractService{{Name: "Svc", FullName: "host.authority.v1.Svc"}}) + if err != nil || pkg != "host.authority.v1" { + t.Fatalf("imported interface package: %q, %v", pkg, err) } }) @@ -677,3 +847,14 @@ func TestServiceContractPackage(t *testing.T) { } }) } + +// A route's path is the full path; the group's path is never prepended to it. +func TestContractRoutesAreNotDoubledByTheirGroupPath(t *testing.T) { + routes := contractRoutes([]*basev0.RestRouteGroup{{Path: "/v1/robin/chat/bounded", Routes: []*basev0.RestRoute{ + {Path: "/v1/robin/chat/bounded/cancel", Method: basev0.HTTPMethod_POST}, + {Path: "/v1/robin/chat/bounded/facets", Method: basev0.HTTPMethod_GET}, + }}}) + if len(routes) != 2 || routes[0].Path != "/v1/robin/chat/bounded/cancel" || routes[0].Method != "POST" || routes[1].Path != "/v1/robin/chat/bounded/facets" { + t.Fatalf("routes: %+v", routes) + } +} diff --git a/cmd/generate/permissions.go b/cmd/generate/permissions.go new file mode 100644 index 000000000..d064cf5dd --- /dev/null +++ b/cmd/generate/permissions.go @@ -0,0 +1,287 @@ +package generate + +import ( + "bytes" + "context" + "fmt" + "go/format" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "unicode" + + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" + "github.com/codefly-dev/cli/cmd/common" + "github.com/codefly-dev/cli/pkg/gitops" + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/runners/golang" + "github.com/codefly-dev/core/shared" + "github.com/codefly-dev/core/wool" + "github.com/spf13/cobra" +) + +var permissionsOutput string +var permissionsCheck bool + +var PermissionsCmd = &cobra.Command{ + Use: "permissions [module]", + Short: "Generate typed Go and TypeScript permissions from published contracts", + Long: `Read the same digest-checked published method policies used by authority delivery. +Generate typed permission constants and a method requirement table in Go and +TypeScript. Run codefly generate contracts first after editing an API. + +By default each Go service gets its own table in its own Go module: +//pkg/gen/permissions, the tree the go-grpc layout +generates its bindings into (source-dir is the Go agent's setting, "code" +unless the service declares another). A service's table holds the methods its +published contracts serve plus the module contract's foreign methods, which +name no service. What an endpoint serves is what the contract catalog records +for it (the declared surface, or every service of its package); a method a +published descriptor declares that no endpoint serves, such as a protocol other +modules serve, is left out with a note and needs no policy. The TypeScript file +sits beside the Go file. A Python service +gets no table: nothing in it reads one, since the authority's hop in front of it +decides every call, so it is skipped with a note. Any other service that +publishes a contract but has no go.mod at its Go source root fails by name. +--output writes the whole module's table to one directory instead. +--check compares every generated file without writing. A served method with no +policy, contradictory policies and colliding identifiers fail by name. This validates vocabulary, +not a person's resource access, installation or consent.`, + Example: "codefly generate permissions\ncodefly generate permissions --check\ncodefly generate permissions --output services/api/code/pkg/gen/permissions", + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + ctx, done := common.NewContext() + defer done() + var module *resources.Module + var err error + if len(args) == 0 { + module, err = common.LoadModule(ctx) + } else { + workspace, loadErr := common.LoadWorkspace(ctx) + if loadErr != nil { + return loadErr + } + module, err = workspace.LoadModuleFromName(ctx, args[0]) + } + if err != nil { + return err + } + return generatePermissions(ctx, module, permissionsOutput, permissionsCheck) + }, +} + +func init() { + PermissionsCmd.Flags().StringVar(&permissionsOutput, "output", "", "write the whole module's table to this one directory (default: each Go service's /pkg/gen/permissions)") + PermissionsCmd.Flags().BoolVar(&permissionsCheck, "check", false, "fail on missing or stale output without writing") +} + +func generatePermissions(ctx context.Context, module *resources.Module, output string, check bool) error { + destinations, skipped, unserved, err := permissionDestinations(ctx, module, output) + if err != nil { + return err + } + if len(unserved) > 0 { + wool.Get(ctx).In("generatePermissions").Info("not in any permissions table: a published descriptor declares these methods but no endpoint of the module serves them", + wool.Field("methods", strings.Join(unserved, ", "))) + } + for _, service := range skipped { + wool.Get(ctx).In("generatePermissions").Info("no permissions table for a Python service: nothing in it reads one", + wool.Field("service", service)) + } + for _, destination := range destinations { + files, err := permissionFiles(destination.methods) + if err != nil { + return err + } + for _, name := range []string{"permissions_gen.go", "permissions_gen.ts"} { + path := filepath.Join(destination.dir, name) + if check { + actual, err := os.ReadFile(path) + if err != nil || !bytes.Equal(actual, files[name]) { + return fmt.Errorf("%s is missing or stale; run codefly generate permissions", path) + } + } else if err := shared.WriteFileAtomic(ctx, path, files[name], 0o644); err != nil { + return err + } + } + } + return nil +} + +// permissionDestination is one directory and the method table it holds. +type permissionDestination struct { + dir string + methods []*compositionv1.MethodPolicyV1 +} + +// permissionDestinations places the module's method policies, and names the +// published methods no endpoint serves, which no table holds. An explicit +// output holds the whole module's table. Otherwise each Go service gets the +// methods its own published contracts serve, plus the foreign methods the +// module contract declares without naming a service, in the generated tree +// of its own Go module, so the service imports them with no flag. A Python +// service that publishes policies is skipped and named in skipped: nothing in +// it reads a table, because the hop in front of it decides every call. +func permissionDestinations(ctx context.Context, module *resources.Module, output string) (destinations []permissionDestination, skipped, unserved []string, err error) { + served, declared, err := gitops.ServiceMethodPolicies(module.Dir()) + if err != nil { + return nil, nil, nil, err + } + if !declared { + return nil, nil, nil, fmt.Errorf("%s publishes no policies; run codefly generate contracts", module.Dir()) + } + if output != "" { + return []permissionDestination{{dir: output, methods: served.All()}}, nil, served.Unserved, nil + } + services, err := module.LoadServices(ctx) + if err != nil { + return nil, nil, nil, err + } + placed := map[string]bool{} + for _, service := range services { + own := served.ByService[service.Name] + if isPythonService(service) { + if len(own) > 0 { + placed[service.Name] = true + skipped = append(skipped, service.Name) + } + continue + } + root, isGo, err := goModuleRoot(service) + if err != nil { + return nil, nil, nil, err + } + if !isGo { + if len(own) > 0 { + return nil, nil, nil, fmt.Errorf("service %s publishes method policies but has no go.mod at %s; pass --output", service.Name, root) + } + continue + } + methods := append(append([]*compositionv1.MethodPolicyV1(nil), own...), served.Foreign...) + if len(methods) == 0 { + continue + } + placed[service.Name] = true + destinations = append(destinations, permissionDestination{dir: filepath.Join(root, "pkg", "gen", "permissions"), methods: methods}) + } + for _, name := range served.Services { + if !placed[name] { + return nil, nil, nil, fmt.Errorf("service %s publishes method policies but is not a service of module %s; pass --output", name, module.Name) + } + } + if len(destinations) == 0 && len(skipped) == 0 { + return nil, nil, nil, fmt.Errorf("module %s declares method policies but has no Go service to generate them into; pass --output", module.Name) + } + return destinations, skipped, served.Unserved, nil +} + +// isPythonService reports whether a service's agent builds Python +// (python-fastapi, python-grpc, ...). +func isPythonService(service *resources.Service) bool { + return service.Agent != nil && strings.HasPrefix(service.Agent.Name, "python") +} + +// goModuleRoot is where a service's go.mod lives: the module root of the Go +// agent's source-dir (core's golang settings, "code" unless the service +// declares another), below the service directory. isGo reports whether a +// go.mod is there. +func goModuleRoot(service *resources.Service) (root string, isGo bool, err error) { + var settings golang.GoAgentSettings + if err := service.LoadSettingsFromSpec(&settings); err != nil { + return "", false, fmt.Errorf("service %s: %w", service.Name, err) + } + if err := settings.Validate(); err != nil { + return "", false, fmt.Errorf("service %s: %w", service.Name, err) + } + moduleRoot, _ := golang.SplitSourceDir(filepath.ToSlash(settings.GoSourceDir())) + root = filepath.Join(service.Dir(), filepath.FromSlash(moduleRoot)) + info, statErr := os.Stat(filepath.Join(root, "go.mod")) + return root, statErr == nil && !info.IsDir(), nil +} + +func permissionIdentifier(raw string) string { + var b strings.Builder + upper := true + for _, r := range raw { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + upper = true + continue + } + if upper { + r = unicode.ToUpper(r) + upper = false + } + b.WriteRune(r) + } + return b.String() +} + +func permissionFiles(methods []*compositionv1.MethodPolicyV1) (map[string][]byte, error) { + // The generator is opt-in: a table names every method, so each declares. + if err := gitops.ValidateMethodPolicies(methods, true); err != nil { + return nil, err + } + names := map[string]string{} + values := map[string]string{} + for _, m := range methods { + for _, sc := range m.GetRequires() { + for _, action := range sc.GetActions() { + value := sc.GetResourceKind() + ":" + action + name := permissionIdentifier(value) + switch name { + case "Permission", "MethodPolicy", "Methods": + return nil, fmt.Errorf("permission %q collides with generated identifier %s", value, name) + } + if previous, ok := names[name]; ok && previous != value { + return nil, fmt.Errorf("permissions %q and %q collide as %s", previous, value, name) + } + names[name] = value + values[value] = name + } + } + } + ordered := make([]string, 0, len(names)) + for name := range names { + ordered = append(ordered, name) + } + sort.Strings(ordered) + var goCode, ts strings.Builder + goCode.WriteString("// Code generated by codefly generate permissions. DO NOT EDIT.\npackage permissions\n\nimport \"strings\"\n\ntype Permission string\nfunc (p Permission) Kind() string { k, _, _ := strings.Cut(string(p),\":\"); return k }\nfunc (p Permission) Action() string { _, a, _ := strings.Cut(string(p),\":\"); return a }\nconst (\n") + ts.WriteString("// Code generated by codefly generate permissions. DO NOT EDIT.\nexport const Permission = {\n") + for _, name := range ordered { + fmt.Fprintf(&goCode, "%s Permission = %q\n", name, names[name]) + fmt.Fprintf(&ts, " %s: %q,\n", name, names[name]) + } + goCode.WriteString(")\ntype MethodPolicy struct { AnyCheckedCaller bool; Requires []Permission }\nfunc Methods() map[string]MethodPolicy { return map[string]MethodPolicy{\n") + ts.WriteString("} as const;\nexport type Permission = typeof Permission[keyof typeof Permission];\nexport const Methods = {\n") + sorted := append([]*compositionv1.MethodPolicyV1(nil), methods...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].GetMethod() < sorted[j].GetMethod() }) + for _, m := range sorted { + required := map[string]bool{} + for _, sc := range m.GetRequires() { + for _, action := range sc.GetActions() { + required[values[sc.GetResourceKind()+":"+action]] = true + } + } + refs := make([]string, 0, len(required)) + for name := range required { + refs = append(refs, name) + } + sort.Strings(refs) + fmt.Fprintf(&goCode, "%s: {AnyCheckedCaller:%t, Requires: []Permission{%s}},\n", strconv.Quote(m.GetMethod()), m.GetAnyCheckedCaller(), strings.Join(refs, ",")) + tsRefs := make([]string, 0, len(refs)) + for _, name := range refs { + tsRefs = append(tsRefs, "Permission."+name) + } + fmt.Fprintf(&ts, "%s: { anyCheckedCaller: %t, requires: [%s] },\n", strconv.Quote(m.GetMethod()), m.GetAnyCheckedCaller(), strings.Join(tsRefs, ",")) + } + goCode.WriteString("} }\n") + ts.WriteString("} as const;\n") + formatted, err := format.Source([]byte(goCode.String())) + if err != nil { + return nil, err + } + return map[string][]byte{"permissions_gen.go": formatted, "permissions_gen.ts": []byte(ts.String())}, nil +} diff --git a/cmd/generate/permissions_test.go b/cmd/generate/permissions_test.go new file mode 100644 index 000000000..a0604774c --- /dev/null +++ b/cmd/generate/permissions_test.go @@ -0,0 +1,365 @@ +package generate + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + + hopv1 "github.com/codefly-dev/authority/libraries/hop/go/gen/authority/hop/v1" + "github.com/codefly-dev/core/composition" + "github.com/codefly-dev/core/resources" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" + "github.com/stretchr/testify/require" +) + +func samplePermissions() []*compositionv1.MethodPolicyV1 { + return []*compositionv1.MethodPolicyV1{ + {Method: "/api/Write", Requires: []*authorityv1.WorkScopeV1{{ResourceKind: "ledger.entry", Actions: []string{"write", "read"}}}}, + {Method: "/api/Version", AnyCheckedCaller: true}, + } +} + +func TestGeneratedPermissionsCompileAndPreserveMethodRequirements(t *testing.T) { + methods := samplePermissions() + files, err := permissionFiles(methods) + require.NoError(t, err) + methods[0], methods[1] = methods[1], methods[0] + again, err := permissionFiles(methods) + require.NoError(t, err) + require.Equal(t, files, again) + dir := t.TempDir() + for name, data := range files { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0644)) + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module permissions_test\n\ngo 1.26.0\n"), 0644)) + test := `package permissions +import "testing" +func TestConsumer(t *testing.T) { + if LedgerEntryRead.Kind()!="ledger.entry" || LedgerEntryRead.Action()!="read" {t.Fatal("wrong permission")} + if len(Methods()["/api/Write"].Requires)!=2 || !Methods()["/api/Version"].AnyCheckedCaller {t.Fatal("policy lost")} +} +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "consumer_test.go"), []byte(test), 0644)) + cmd := exec.Command("go", "test", "./...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "consumer_test.go"), []byte(strings.Replace(test, "LedgerEntryRead.Kind()", "UndeclaredPermission.Kind()", 1)), 0644)) + cmd = exec.Command("go", "test", "./...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + out, err = cmd.CombinedOutput() + require.Error(t, err) + require.Contains(t, string(out), "undefined: UndeclaredPermission") + require.True(t, bytes.Contains(files["permissions_gen.ts"], []byte("LedgerEntryRead: \"ledger.entry:read\""))) +} + +func TestPermissionGenerationRefusesCollisionsAndMissingPolicies(t *testing.T) { + _, reservedErr := permissionFiles([]*compositionv1.MethodPolicyV1{{Method: "/api/Reserved", Requires: []*authorityv1.WorkScopeV1{{ResourceKind: "method", Actions: []string{"policy"}}}}}) + require.ErrorContains(t, reservedErr, "generated identifier MethodPolicy") + methods := samplePermissions() + methods = append(methods, &compositionv1.MethodPolicyV1{Method: "/api/Other", Requires: []*authorityv1.WorkScopeV1{{ResourceKind: "ledger_entry", Actions: []string{"read"}}}}) + _, err := permissionFiles(methods) + require.ErrorContains(t, err, "collide") + _, err = permissionFiles([]*compositionv1.MethodPolicyV1{{Method: "/api/Missing"}}) + require.ErrorContains(t, err, "no permission policy") +} + +// permissionsModule writes a module "ledger" with two Go services, api +// (source-dir defaulted) and admin (source-dir go/cmd/admin, so its go.mod is +// at go/), each publishing one protobuf contract with its own marked methods, +// a Python service engine publishing one too, a store service with no Go +// module and no contract, and a module contract declaring one foreign method. +// It returns the loaded module. +func permissionsModule(t *testing.T) *resources.Module { + t.Helper() + dir := t.TempDir() + write := func(rel, content string) { + t.Helper() + path := filepath.Join(dir, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + } + write("module.codefly.yaml", "kind: module\nname: ledger\nservices:\n - name: api\n - name: admin\n - name: engine\n - name: store\n") + agent := "agent:\n kind: codefly:service\n name: go-grpc\n version: 0.0.1\n publisher: codefly.dev\n" + write("services/api/service.codefly.yaml", "name: api\nversion: 0.0.0\n"+agent) + write("services/admin/service.codefly.yaml", "name: admin\nversion: 0.0.0\n"+agent+"spec:\n source-dir: go/cmd/admin\n") + write("services/store/service.codefly.yaml", "name: store\nversion: 0.0.0\nagent:\n kind: codefly:service\n name: postgres\n version: 0.0.1\n publisher: codefly.dev\nspec:\n database-name: ledger\n") + write("services/engine/service.codefly.yaml", "name: engine\nversion: 0.0.0\nagent:\n kind: codefly:service\n name: python-fastapi\n version: 0.0.1\n publisher: codefly.dev\nspec:\n Settings:\n source-dir: \"\"\n") + write("services/engine/code/pyproject.toml", "[project]\nname = \"engine\"\nversion = \"0.0.0\"\n") + write("services/engine/code/src/main.py", "") + write("services/api/code/go.mod", "module example.com/ledger/api\n\ngo 1.26.0\n") + write("services/admin/go/go.mod", "module example.com/ledger/admin\n\ngo 1.26.0\n") + write("module.contract.codefly.yaml", `schema: codefly/module-contract/v1 +principal: ledger +namespaces: [] +queues: [] +scope_ceilings: + - resource_kind: ledger.entry + actions: [read, write, approve] +bindings: [] +destinations: [] +foreign_methods: + - method: /otlp.TraceService/Export + requires: + - resource_kind: ledger.entry + actions: [write] +`) + contract := func(service, method, action string) string { + m := &descriptorpb.MethodDescriptorProto{Name: proto.String(method), InputType: proto.String(".ledger.Empty"), OutputType: proto.String(".ledger.Empty"), Options: &descriptorpb.MethodOptions{}} + proto.SetExtension(m.Options, hopv1.E_Policy, &hopv1.Policy{Requires: []*hopv1.Requirement{{ResourceKind: "ledger.entry", Actions: []string{action}}}}) + set, err := proto.Marshal(&descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{{ + Name: proto.String(service + ".proto"), Package: proto.String("ledger"), Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{{Name: proto.String("Empty")}}, + Service: []*descriptorpb.ServiceDescriptorProto{{Name: proto.String(permissionIdentifier(service) + "Service"), Method: []*descriptorpb.MethodDescriptorProto{m}}}, + }}}) + require.NoError(t, err) + write("contracts/api/"+service+"/grpc/contract.binpb", string(set)) + sum := sha256.Sum256(set) + return ` - name: ` + service + ` + endpoints: [grpc] + api-contracts: + - endpoint: grpc + kind: protobuf + package: ledger + path: contracts/api/` + service + `/grpc/contract.binpb + digest: sha256:` + hex.EncodeToString(sum[:]) + "\n" + } + write("module.package.codefly.yaml", `schema: codefly/module-package/v2 +kind: module-package +id: codefly/ledger +version: 0.1.0 +minimum-codefly-version: ">=0.0.0" +artifact-roots: + - contracts + - services +services: +`+contract("api", "Read", "read")+contract("admin", "Approve", "approve")+contract("engine", "Recompute", "write")+`contracts: + composition: ">=2.0.0 <3.0.0" +`) + module, err := resources.LoadModuleFromDir(t.Context(), dir) + require.NoError(t, err) + return module +} + +func TestPermissionsDefaultLandsInEachGoServiceModule(t *testing.T) { + module := permissionsModule(t) + require.NoError(t, generatePermissions(t.Context(), module, "", false)) + require.NoError(t, generatePermissions(t.Context(), module, "", true)) + require.NoDirExists(t, filepath.Join(module.Dir(), "pkg")) + require.NoDirExists(t, filepath.Join(module.Dir(), "services", "store", "code")) + // The Python service publishes policies and gets no table, and its methods + // are in no Go service's table. + require.NoDirExists(t, filepath.Join(module.Dir(), "services", "engine", "code", "pkg")) + require.NoDirExists(t, filepath.Join(module.Dir(), "services", "engine", "code", "src", "gen")) + + api := filepath.Join(module.Dir(), "services", "api", "code", "pkg", "gen", "permissions") + admin := filepath.Join(module.Dir(), "services", "admin", "go", "pkg", "gen", "permissions") + read := func(dir, name string) string { + data, err := os.ReadFile(filepath.Join(dir, name)) + require.NoError(t, err) + return string(data) + } + // Each service's own methods, and the foreign method the module contract + // names no service for in both. + require.Contains(t, read(api, "permissions_gen.go"), `"/ledger.ApiService/Read"`) + require.NotContains(t, read(api, "permissions_gen.go"), "AdminService") + require.Contains(t, read(admin, "permissions_gen.go"), `"/ledger.AdminService/Approve"`) + require.NotContains(t, read(admin, "permissions_gen.go"), "ApiService") + for _, dir := range []string{api, admin} { + require.Contains(t, read(dir, "permissions_gen.go"), `"/otlp.TraceService/Export"`) + require.Contains(t, read(dir, "permissions_gen.ts"), `"/otlp.TraceService/Export"`) + require.NotContains(t, read(dir, "permissions_gen.go"), "EngineService") + } + + // The api service imports its table as a package of its own go.mod. + code := filepath.Join(module.Dir(), "services", "api", "code") + consumer := `package consumer +import ( + "testing" + "example.com/ledger/api/pkg/gen/permissions" +) +func TestConsumer(t *testing.T) { + if permissions.Methods()["/ledger.ApiService/Read"].Requires[0] != permissions.LedgerEntryRead { + t.Fatal("policy lost") + } +} +` + require.NoError(t, os.MkdirAll(filepath.Join(code, "consumer"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(code, "consumer", "consumer_test.go"), []byte(consumer), 0o644)) + cmd := exec.Command("go", "test", "./...") + cmd.Dir = code + cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=-mod=mod") + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) +} + +func TestPermissionsCheckDetectsDriftWithoutWriting(t *testing.T) { + module := permissionsModule(t) + require.NoError(t, generatePermissions(t.Context(), module, "", false)) + require.NoError(t, generatePermissions(t.Context(), module, "", true)) + path := filepath.Join(module.Dir(), "services", "admin", "go", "pkg", "gen", "permissions", "permissions_gen.go") + before, err := os.Stat(path) + require.NoError(t, err) + contract := filepath.Join(module.Dir(), "module.contract.codefly.yaml") + raw, err := os.ReadFile(contract) + require.NoError(t, err) + require.NoError(t, os.WriteFile(contract, []byte(strings.Replace(string(raw), "actions: [write]\n", "actions: [read]\n", 1)), 0o644)) + require.ErrorContains(t, generatePermissions(t.Context(), module, "", true), "stale") + after, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, before.ModTime(), after.ModTime()) + require.NoError(t, generatePermissions(t.Context(), module, "", false)) + require.NoError(t, generatePermissions(t.Context(), module, "", true)) + ts := filepath.Join(module.Dir(), "services", "api", "code", "pkg", "gen", "permissions", "permissions_gen.ts") + require.NoError(t, os.Remove(ts)) + require.ErrorContains(t, generatePermissions(t.Context(), module, "", true), ts) +} + +func TestPermissionsOutputOverrideHoldsTheWholeModule(t *testing.T) { + module := permissionsModule(t) + output := filepath.Join(t.TempDir(), "permissions") + require.NoError(t, generatePermissions(t.Context(), module, output, false)) + require.NoError(t, generatePermissions(t.Context(), module, output, true)) + data, err := os.ReadFile(filepath.Join(output, "permissions_gen.go")) + require.NoError(t, err) + for _, method := range []string{"/ledger.ApiService/Read", "/ledger.AdminService/Approve", "/ledger.EngineService/Recompute", "/otlp.TraceService/Export"} { + require.Contains(t, string(data), strconv.Quote(method)) + } + require.NoDirExists(t, filepath.Join(module.Dir(), "services", "api", "code", "pkg", "gen")) + require.ErrorContains(t, generatePermissions(t.Context(), module, "", true), "missing or stale") +} + +func TestPermissionsRefuseAContractServiceWithNoGoModule(t *testing.T) { + module := permissionsModule(t) + require.NoError(t, os.Remove(filepath.Join(module.Dir(), "services", "admin", "go", "go.mod"))) + require.ErrorContains(t, generatePermissions(t.Context(), module, "", false), "service admin publishes method policies but has no go.mod") +} + +// TestPermissionsSkipAPythonServiceByName pins the mixed module: compute's +// shape, a Go service beside Python services that publish contracts. The Go +// services get their tables; the Python one is named as skipped, never a +// refusal, because nothing in it reads a table (the hop decides every call). +func TestPermissionsSkipAPythonServiceByName(t *testing.T) { + module := permissionsModule(t) + destinations, skipped, _, err := permissionDestinations(t.Context(), module, "") + require.NoError(t, err) + require.Equal(t, []string{"engine"}, skipped) + var dirs []string + for _, destination := range destinations { + dirs = append(dirs, destination.dir) + } + require.ElementsMatch(t, []string{ + filepath.Join(module.Dir(), "services", "api", "code", "pkg", "gen", "permissions"), + filepath.Join(module.Dir(), "services", "admin", "go", "pkg", "gen", "permissions"), + }, dirs) + + // A Python service publishing policies is skipped even with a go.mod + // lying in its tree: the agent says what the service is built from. + require.NoError(t, os.WriteFile(filepath.Join(module.Dir(), "services", "engine", "code", "go.mod"), []byte("module example.com/ledger/engine\n\ngo 1.26.0\n"), 0o644)) + _, skipped, _, err = permissionDestinations(t.Context(), module, "") + require.NoError(t, err) + require.Equal(t, []string{"engine"}, skipped) +} + +// servedCatalog writes the module's contract catalog as generate contracts +// does, every service of each endpoint's package served, except the services +// unserved names on the api endpoint. +func servedCatalog(t *testing.T, module *resources.Module, unserved ...string) { + t.Helper() + manifest, err := composition.LoadPackageManifest(module.Dir()) + require.NoError(t, err) + catalog := &composition.APIContractCatalog{Schema: composition.APIContractCatalogSchema, Package: manifest.ID, Version: manifest.Version} + for _, service := range manifest.Services { + for _, contract := range service.APIContracts { + data, err := os.ReadFile(filepath.Join(module.Dir(), contract.Path)) + require.NoError(t, err) + var set descriptorpb.FileDescriptorSet + require.NoError(t, proto.Unmarshal(data, &set)) + var services []composition.APIContractService + for _, s := range composition.ProtobufServices(&set, contract.Package) { + if service.Name != "api" || !slices.Contains(unserved, s.FullName) { + services = append(services, s) + } + } + catalog.Endpoints = append(catalog.Endpoints, composition.APIContractEndpoint{ + Service: service.Name, Endpoint: contract.Endpoint, API: "grpc", Kind: contract.Kind, + Package: contract.Package, Path: contract.Path, Digest: contract.Digest, Services: services, + }) + } + } + data, err := catalog.CanonicalBytes() + require.NoError(t, err) + path := filepath.Join(module.Dir(), filepath.FromSlash(composition.APIContractCatalogFileName)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, data, 0o644)) +} + +// TestPermissionsCoverTheServedMethodsOnly pins a module that publishes a +// protocol it does not serve: the api contract's descriptor carries +// TargetAuthorityService, unmarked, beside its own ApiService, as a module +// publishing an owner protocol for other modules to serve does. The catalog +// records api's endpoint serving ApiService alone, so the protocol's method is +// in no table, needs no policy and is named as unserved; a served method with +// no policy is still refused by name. +func TestPermissionsCoverTheServedMethodsOnly(t *testing.T) { + module := permissionsModule(t) + read := &descriptorpb.MethodDescriptorProto{Name: proto.String("Read"), InputType: proto.String(".ledger.Empty"), OutputType: proto.String(".ledger.Empty"), Options: &descriptorpb.MethodOptions{}} + proto.SetExtension(read.Options, hopv1.E_Policy, &hopv1.Policy{Requires: []*hopv1.Requirement{{ResourceKind: "ledger.entry", Actions: []string{"read"}}}}) + check := &descriptorpb.MethodDescriptorProto{Name: proto.String("Check"), InputType: proto.String(".ledger.Empty"), OutputType: proto.String(".ledger.Empty")} + set, err := proto.Marshal(&descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{{ + Name: proto.String("api.proto"), Package: proto.String("ledger"), Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{{Name: proto.String("Empty")}}, + Service: []*descriptorpb.ServiceDescriptorProto{ + {Name: proto.String("ApiService"), Method: []*descriptorpb.MethodDescriptorProto{read}}, + {Name: proto.String("TargetAuthorityService"), Method: []*descriptorpb.MethodDescriptorProto{check}}, + }, + }}}) + require.NoError(t, err) + contractPath := filepath.Join(module.Dir(), "contracts", "api", "api", "grpc", "contract.binpb") + before, err := os.ReadFile(contractPath) + require.NoError(t, err) + require.NoError(t, os.WriteFile(contractPath, set, 0o644)) + manifestPath := filepath.Join(module.Dir(), "module.package.codefly.yaml") + manifest, err := os.ReadFile(manifestPath) + require.NoError(t, err) + digest := func(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) } + require.NoError(t, os.WriteFile(manifestPath, bytes.Replace(manifest, []byte(digest(before)), []byte(digest(set)), 1), 0o644)) + + // With no catalog, an endpoint serves every service of its package: the + // unmarked Check is served, and refused by name. + require.ErrorContains(t, generatePermissions(t.Context(), module, "", false), "/ledger.TargetAuthorityService/Check") + + servedCatalog(t, module, "ledger.TargetAuthorityService") + _, _, unserved, err := permissionDestinations(t.Context(), module, "") + require.NoError(t, err) + require.Equal(t, []string{"/ledger.TargetAuthorityService/Check"}, unserved) + require.NoError(t, generatePermissions(t.Context(), module, "", false)) + require.NoError(t, generatePermissions(t.Context(), module, "", true)) + table, err := os.ReadFile(filepath.Join(module.Dir(), "services", "api", "code", "pkg", "gen", "permissions", "permissions_gen.go")) + require.NoError(t, err) + require.Contains(t, string(table), `"/ledger.ApiService/Read"`) + require.NotContains(t, string(table), "TargetAuthorityService") + output := filepath.Join(t.TempDir(), "permissions") + require.NoError(t, generatePermissions(t.Context(), module, output, false)) + whole, err := os.ReadFile(filepath.Join(output, "permissions_gen.go")) + require.NoError(t, err) + require.NotContains(t, string(whole), "TargetAuthorityService") + + // The catalog recording the protocol as served makes Check a served + // method with no policy: refused by name, as every served method is. + servedCatalog(t, module) + require.ErrorContains(t, generatePermissions(t.Context(), module, "", true), "/ledger.TargetAuthorityService/Check") +} diff --git a/cmd/generate/proto.go b/cmd/generate/proto.go index dd1959e5e..1703c139e 100644 --- a/cmd/generate/proto.go +++ b/cmd/generate/proto.go @@ -258,12 +258,10 @@ func generateProtoCode(ctx context.Context, protoDir string, outputDir string) ( w.Info("Generating proto code...") - // The input, template and path filters are absolute: a custom template may - // live outside the proto directory, and the staged template always does. - pathArgs := protoGenerationPathArgs(containerProto, true) - args := make([]string, 0, 4+len(pathArgs)) - args = append(args, "generate", containerProto, "--template", path.Join(protoStagingRoot, protoStagedTemplateName)) - args = append(args, pathArgs...) + args, err := protoGenerationArgs(containerProto, path.Join(protoStagingRoot, protoStagedTemplateName), stagedTemplate) + if err != nil { + return err + } proc, err = runner.NewProcess("buf", args...) if err != nil { return w.Wrapf(err, "cannot create process") @@ -947,6 +945,24 @@ func protoGenerationPathArgs(protoDir string, absolute bool) []string { return args } +// A positional Buf input replaces the template's inputs, including their path +// filters. Supply the default only when the template leaves input selection to +// the caller. Relative template inputs resolve in the template's working dir. +func protoGenerationArgs(protoDir, templatePath string, template []byte) ([]string, error) { + var selection struct { + Inputs []yaml.Node `yaml:"inputs"` + } + if err := yaml.Unmarshal(template, &selection); err != nil { + return nil, fmt.Errorf("cannot read generation template inputs: %w", err) + } + args := []string{"generate"} + if len(selection.Inputs) == 0 { + args = append(args, protoDir) + } + args = append(args, "--template", templatePath) + return append(args, protoGenerationPathArgs(protoDir, true)...), nil +} + func init() { ProtoCmd.Flags().StringVar(&protoDir, "proto", "", "path to proto source directory (required)") ProtoCmd.Flags().StringVar(&outputDir, "output", "", "path to output directory with buf.gen.yaml (required)") diff --git a/cmd/generate/proto_companion_test.go b/cmd/generate/proto_companion_test.go index 4d02e6b7e..fc7520a1a 100644 --- a/cmd/generate/proto_companion_test.go +++ b/cmd/generate/proto_companion_test.go @@ -78,3 +78,60 @@ plugins: require.NoError(t, err) require.Equal(t, first, second) } + +func TestProtoTemplateInputsAndPathsThroughPinnedCompanion(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "workspace.codefly.yaml"), "name: example\n") + input := filepath.Join(root, "proto") + for _, dir := range []string{"proto", "second"} { + writeTestFile(t, filepath.Join(root, dir, "buf.yaml"), "version: v2\nmodules:\n - path: .\n") + } + for _, name := range []string{"proto/selected", "proto/excluded", "second/extra"} { + writeTestFile(t, filepath.Join(root, name+".proto"), `syntax = "proto3"; +package example.v1; +option go_package = "example.com/generated;example"; +message `+filepath.Base(name)+` { string value = 1; } +`) + } + writeTestFile(t, filepath.Join(input, "buf.gen.yaml"), `version: v2 +inputs: + - directory: . + paths: [selected.proto] + - directory: ../second +plugins: + - local: protoc-gen-go + out: ../code/gen + opt: paths=source_relative +`) + oldTemplate, oldLocal, oldPaths := protoTemplate, protoLocal, protoPaths + t.Cleanup(func() { protoTemplate, protoLocal, protoPaths = oldTemplate, oldLocal, oldPaths }) + protoTemplate, protoLocal, protoPaths = "", false, nil + require.NoError(t, generateProtoCode(t.Context(), input, input)) + _, err := os.Stat(filepath.Join(root, "code/gen/excluded.pb.go")) + require.ErrorIs(t, err, os.ErrNotExist, "a positional input must not replace the template's path selection") + for _, name := range []string{"selected", "extra"} { + contents, err := os.ReadFile(filepath.Join(root, "code/gen", name+".pb.go")) + require.NoError(t, err, "every template input must be generated") + require.Contains(t, string(contents), "package example") + } + // An explicit caller selection retains Buf's override semantics, rather + // than intersecting with the template's selected.proto list. + writeTestFile(t, filepath.Join(input, "buf.gen.yaml"), `version: v2 +clean: true +inputs: + - directory: . + paths: [selected.proto] +plugins: + - local: protoc-gen-go + out: ../code/gen + opt: paths=source_relative +`) + protoPaths = []string{"excluded.proto"} + require.NoError(t, generateProtoCode(t.Context(), input, input)) + _, err = os.Stat(filepath.Join(root, "code/gen/excluded.pb.go")) + require.NoError(t, err) + for _, name := range []string{"selected", "extra"} { + _, err := os.Stat(filepath.Join(root, "code/gen", name+".pb.go")) + require.ErrorIs(t, err, os.ErrNotExist) + } +} diff --git a/cmd/generate/proto_test.go b/cmd/generate/proto_test.go index 45c9104c1..7e20d3c31 100644 --- a/cmd/generate/proto_test.go +++ b/cmd/generate/proto_test.go @@ -33,6 +33,29 @@ func TestProtoGenerationPathArgs(t *testing.T) { } } +func TestProtoGenerationArgsPreserveBufInputSelection(t *testing.T) { + previous := protoPaths + t.Cleanup(func() { protoPaths = previous }) + for _, tc := range []struct { + name, template string + paths, want []string + }{ + {"v1 default", "version: v1\n", nil, []string{"generate", "/workspace/proto", "--template", "/staging/template.yaml"}}, + {"v2 default", "version: v2\n", nil, []string{"generate", "/workspace/proto", "--template", "/staging/template.yaml"}}, + {"empty inputs", "version: v2\ninputs: []\n", nil, []string{"generate", "/workspace/proto", "--template", "/staging/template.yaml"}}, + {"declared selection", "version: v2\ninputs:\n - directory: .\n paths: [api.proto]\n - directory: ../other\n", nil, []string{"generate", "--template", "/staging/template.yaml"}}, + {"caller override", "version: v2\ninputs:\n - directory: .\n paths: [api.proto]\n", []string{"chosen.proto"}, []string{"generate", "--template", "/staging/template.yaml", "--path", "/workspace/proto/chosen.proto"}}, + } { + t.Run(tc.name, func(t *testing.T) { + protoPaths = tc.paths + got, err := protoGenerationArgs("/workspace/proto", "/staging/template.yaml", []byte(tc.template)) + if err != nil || !reflect.DeepEqual(got, tc.want) { + t.Fatalf("Buf invocation = %v, %v; want %v", got, err, tc.want) + } + }) + } +} + func TestResolveProtoTemplate(t *testing.T) { root := t.TempDir() input, output := filepath.Join(root, "proto"), filepath.Join(root, "generated") diff --git a/cmd/generate/runnable_bindings.go b/cmd/generate/runnable_bindings.go index a7a120e4e..b3655ff41 100644 --- a/cmd/generate/runnable_bindings.go +++ b/cmd/generate/runnable_bindings.go @@ -21,7 +21,6 @@ import ( "github.com/codefly-dev/core/configurations" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" runnablev0 "github.com/codefly-dev/core/generated/go/codefly/runnable/v0" - "github.com/codefly-dev/core/network" "github.com/codefly-dev/core/resources" corerunnable "github.com/codefly-dev/core/runnable" "github.com/codefly-dev/core/standards" @@ -38,8 +37,10 @@ const RunnableBindingsGroup = runnablespkg.RunnableBindingsGroup const runnableBindingsFileHeader = "# Generated by `codefly generate runnable-bindings`. DO NOT EDIT: regenerate.\n" var ( - runnableBindingsEnv string - runnableBindingsCheck bool + runnableBindingsEnv string + runnableBindingsCheck bool + runnableBindingsNamingScope string + runnableBindingsCallEndpoint string ) // RunnableBindingsCmd prepares, for one environment, the binding of every @@ -77,10 +78,27 @@ here, by name, rather than at a call. An operation published directly on a Connect endpoint uses that endpoint. The address is resolved, never configured: it is the one a run or a render of -the same environment gives the owner. A service that installs derived +the same environment gives the owner. Locally that is the address of the run's +naming scope: the environment's declared naming-scope, or --naming-scope, which +like ` + "`codefly run`" + ` overrides it for this invocation (an explicit empty value +clears it). A live run of that scope answers with its concrete allocation; with +no run the address is the one the run will bind. A service that installs derived operations declares ` + RunnableBindingsGroup + ` as a workspace configuration dependency and receives them like any other group, so the installer names no owner module. +An environment's runnable-call-endpoint: // in +workspace.codefly.yaml sends every call prepared for it to that declared +endpoint instead of the owner's listener: the composition's hop in front of its +modules, for example. Declared there, the file and its --check depend on the +workspace alone. --call-endpoint overrides the declaration for one invocation, +and an explicitly empty value sends each call to its owner. It must be an HTTP-based +endpoint of a workspace module, and it is resolved like an owner's (same +environment, same naming scope). Only the address moves: the owner is still +loaded and held to its served surface and published contract, and the +operation, the Connect procedure or REST verb and path, the contract, its +digest and the policy stay the owner's. The endpoint forwards the call; the +owner must still serve it. + In workspace.codefly.yaml, environments[].runnable-scope-selections maps each binding key (MODULE__OPERATION) to Core ScopeSelection messages: slot, invoke, and lookup. Every required slot needs exact resource kinds, actions and ids; @@ -109,9 +127,11 @@ binding. Examples: codefly generate runnable-bindings codefly generate runnable-bindings --env staging - codefly generate runnable-bindings --env staging --check`, + codefly generate runnable-bindings --env staging --check + codefly generate runnable-bindings --env local --naming-scope demo --call-endpoint authority/hop/http + codefly generate runnable-bindings --env local --call-endpoint "" # owners, despite a declared runnable-call-endpoint`, Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { ctx, done := common.NewContext() defer done() ctx, stop := common.SignalContext(ctx) @@ -121,11 +141,12 @@ Examples: if err != nil { return err } - env, err := environments.Select(workspace, runnableBindingsEnv) + env, err := runnableBindingsEnvironment(workspace, cmd.Flags().Changed("naming-scope")) if err != nil { return err } - prepared, err := prepareRunnableBindings(ctx, workspace, env) + callEndpoint := runnableBindingsCallEndpointFor(env, cmd.Flags().Changed("call-endpoint")) + prepared, err := prepareRunnableBindings(ctx, workspace, env, callEndpoint) if err != nil { return err } @@ -147,7 +168,7 @@ Examples: current = errors.Is(err, os.ErrNotExist) } if !current { - return fmt.Errorf("%s is out of date: run `codefly generate runnable-bindings --env %s`", target, env.Name) + return fmt.Errorf("%s is out of date: run `%s`", target, runnableBindingsRegenerate(env, callEndpoint)) } cli.Info("runnable-bindings: %s is current (%d operation(s), %d declined)", target, prepared.count, len(prepared.declined)) return nil @@ -176,6 +197,52 @@ Examples: func init() { RunnableBindingsCmd.Flags().StringVar(&runnableBindingsEnv, "env", "local", "environment supplying resolved addresses and runnable-scope-selections") RunnableBindingsCmd.Flags().BoolVar(&runnableBindingsCheck, "check", false, "do not write; exit 1 if the on-disk file differs from what would be generated") + RunnableBindingsCmd.Flags().StringVar(&runnableBindingsNamingScope, "naming-scope", "", "`scope` addresses resolve in, as codefly run --naming-scope reads it: omitted keeps the environment's, empty clears it") + RunnableBindingsCmd.Flags().StringVar(&runnableBindingsCallEndpoint, "call-endpoint", "", "declared HTTP-based endpoint, as `module/service/endpoint`, every prepared call is sent to instead of the owner's listener, overriding the environment's runnable-call-endpoint (empty: each owner's listener; the owner's operation, route, contract and policy are kept)") +} + +// runnableBindingsEnvironment selects the environment the bindings are +// prepared for. Like `codefly run` (cmd/run runEnvironment), --naming-scope +// overrides the declared scope on this invocation's copy only: an absent flag +// keeps it, an explicitly empty one clears it. Addresses are then those of the +// run that names the same scope. +func runnableBindingsEnvironment(workspace *resources.Workspace, explicitScope bool) (*environments.Environment, error) { + env, err := environments.Select(workspace, runnableBindingsEnv) + if err != nil { + return nil, err + } + if explicitScope { + env.NamingScope = runnableBindingsNamingScope + } + return env, nil +} + +// runnableBindingsCallEndpointFor is where this invocation sends every +// prepared call: the environment's runnable-call-endpoint, or --call-endpoint +// when it is passed, which overrides it for this invocation (an explicit empty +// value sends calls to each owner's own listener). +func runnableBindingsCallEndpointFor(env *environments.Environment, explicit bool) string { + if explicit { + return runnableBindingsCallEndpoint + } + return env.RunnableCallEndpoint +} + +// runnableBindingsRegenerate is the command that regenerates what --check +// compared against, with the scope and call endpoint it was prepared for: a +// call endpoint the environment declares needs no flag. +func runnableBindingsRegenerate(env *environments.Environment, callEndpoint string) string { + command := "codefly generate runnable-bindings --env " + env.Name + if env.NamingScope != "" { + command += " --naming-scope " + env.NamingScope + } + if callEndpoint != env.RunnableCallEndpoint { + if callEndpoint == "" { + callEndpoint = `""` + } + command += " --call-endpoint " + callEndpoint + } + return command } // preparedRunnableBindings is what one environment's preparation produced: @@ -198,7 +265,10 @@ type preparedRunnableBindings struct { // file records each decline as a comment, so a consumer refused for the // missing key finds why next to the keys that are there, and `--check` sees a // decline added or removed. -func prepareRunnableBindings(ctx context.Context, workspace *resources.Workspace, env *environments.Environment) (*preparedRunnableBindings, error) { +// +// callEndpoint, when set, is the // every prepared +// call is sent to in place of the owner's listener (see callThrough). +func prepareRunnableBindings(ctx context.Context, workspace *resources.Workspace, env *environments.Environment, callEndpoint string) (*preparedRunnableBindings, error) { modules, err := workspace.LoadModules(ctx) if err != nil { return nil, err @@ -207,6 +277,11 @@ func prepareRunnableBindings(ctx context.Context, workspace *resources.Workspace if err != nil { return nil, err } + if callEndpoint != "" { + if err := resolver.callThrough(ctx, callEndpoint); err != nil { + return nil, err + } + } lines := map[string]string{} derivedKeys := map[string]bool{} var declined []string @@ -398,6 +473,11 @@ func prepareRunnable(ctx context.Context, module *resources.Module, operation *r if err = verifyPublishedContract(module, operation.Entry.Service, operation.Entry.Endpoint, kind); err != nil { return nil, err } + // The owner served and published the operation; only now may a declared + // call endpoint stand in for its listener. The route stays the owner's. + if resolver.through != "" { + call.Address = resolver.through + } codes := corerunnable.GRPCStatusNames if kind == composition.APIContractKindOpenAPI { codes = corerunnable.HTTPStatusCodes @@ -513,6 +593,10 @@ type endpointResolver struct { workspace *resources.Workspace env *environments.Environment remote *remotenetwork.RemoteManager + // through is the resolved address of the declared call endpoint every + // prepared call is sent to instead of the owner's listener; empty sends + // each call to its owner. + through string } func newEndpointResolver(ctx context.Context, workspace *resources.Workspace, env *environments.Environment) (*endpointResolver, error) { @@ -585,6 +669,51 @@ func (r *endpointResolver) callTarget(ctx context.Context, module *resources.Mod } } +// callThrough selects the endpoint the composition declares in front of the +// owners — its hop, for example — as where every prepared call is sent. The +// coordinate names a declared endpoint of a workspace module; it is parsed by +// Core's endpoint-reference parser and resolved exactly as an owner's endpoint +// is, in the same environment and naming scope. A prepared call carries an +// HTTP base URL, so the endpoint must be HTTP-based. The generator infers no +// such endpoint: a composition that wants one says so. +func (r *endpointResolver) callThrough(ctx context.Context, coordinate string) error { + reference, err := resources.ParseEndpointReference(coordinate) + if err != nil { + return fmt.Errorf("--call-endpoint %q: %w", coordinate, err) + } + if reference.Name == "" { + return fmt.Errorf("--call-endpoint %q names no endpoint: it is //", coordinate) + } + module, err := r.workspace.LoadModuleFromName(ctx, reference.Module) + if err != nil { + return fmt.Errorf("--call-endpoint %q: %w", coordinate, err) + } + identity, endpoints, err := r.load(ctx, module, reference.Service) + if err != nil { + return fmt.Errorf("--call-endpoint %q: %w", coordinate, err) + } + endpoint := endpointNamed(endpoints, reference.Name) + if endpoint == nil { + declared := make([]string, 0, len(endpoints)) + for _, candidate := range endpoints { + declared = append(declared, candidate.GetName()) + } + sort.Strings(declared) + return fmt.Errorf("--call-endpoint %q: service %s/%s declares no endpoint %s (it declares %v)", coordinate, reference.Module, reference.Service, reference.Name, declared) + } + if reference.API != "" && reference.API != endpoint.GetApi() { + return fmt.Errorf("--call-endpoint %q: %s/%s/%s is a %s endpoint", coordinate, reference.Module, reference.Service, reference.Name, endpoint.GetApi()) + } + if !standards.IsHTTPBasedAPI(endpoint.GetApi()) { + return fmt.Errorf("--call-endpoint %q: %s/%s/%s is a %s endpoint, and a prepared call is sent as HTTP: name an HTTP-based endpoint (%s, %s, %s or %s)", + coordinate, reference.Module, reference.Service, reference.Name, endpoint.GetApi(), standards.HTTP, standards.CONNECT, standards.REST, standards.MCP) + } + if r.through, err = r.address(ctx, identity, endpoints, endpoint); err != nil { + return fmt.Errorf("--call-endpoint %q: %w", coordinate, err) + } + return nil +} + func endpointNamed(endpoints []*basev0.Endpoint, name string) *basev0.Endpoint { for _, candidate := range endpoints { if candidate.GetName() == name { @@ -659,13 +788,21 @@ func (r *endpointResolver) load(ctx context.Context, module *resources.Module, s // gives it: the native address locally, the in-cluster Service in a Kubernetes // environment. An HTTP-based endpoint resolves to a base URL, which is what a // prepared call carries. +// +// Locally the address is the one of the environment's naming scope: the +// concrete allocation of a live run of that scope when one answers, else the +// one a run of it binds (common.ResolvePreferredNative, the resolver +// `codefly endpoint` uses). func (r *endpointResolver) address(ctx context.Context, identity *resources.ServiceIdentity, endpoints []*basev0.Endpoint, endpoint *basev0.Endpoint) (string, error) { if r.env.Local() { - instance := network.NativeFor(ctx, r.workspace.Name, identity.Module, identity.Name, r.env.NamingScope, endpoint) - if instance == nil { + resolved, err := common.ResolvePreferredNative(ctx, r.workspace.Name, identity.Module, identity.Name, r.env.NamingScope, resources.EndpointFromProto(endpoint)) + if err != nil { + return "", fmt.Errorf("%s/%s/%s: %w", identity.Module, identity.Name, endpoint.GetName(), err) + } + if resolved.Address == "" { return "", fmt.Errorf("no native address for %s/%s/%s", identity.Module, identity.Name, endpoint.GetName()) } - return instance.GetAddress(), nil + return resolved.Address, nil } mappings, err := r.remote.GenerateNetworkMappings(ctx, r.env, r.workspace, identity, endpoints) if err != nil { diff --git a/cmd/generate/runnable_bindings_test.go b/cmd/generate/runnable_bindings_test.go index 65d43a2e5..984d0769f 100644 --- a/cmd/generate/runnable_bindings_test.go +++ b/cmd/generate/runnable_bindings_test.go @@ -19,8 +19,15 @@ import ( func resetRunnableBindingsFlags(t *testing.T) { t.Helper() - runnableBindingsEnv, runnableBindingsCheck = "local", false - t.Cleanup(func() { runnableBindingsEnv, runnableBindingsCheck = "local", false }) + reset := func() { + runnableBindingsEnv, runnableBindingsCheck = "local", false + runnableBindingsNamingScope, runnableBindingsCallEndpoint = "", "" + for _, name := range []string{"naming-scope", "call-endpoint"} { + RunnableBindingsCmd.Flags().Lookup(name).Changed = false + } + } + reset() + t.Cleanup(reset) } // Every derived operation is prepared as the tiny binding a caller installs: diff --git a/cmd/generate/runnable_call_endpoint_test.go b/cmd/generate/runnable_call_endpoint_test.go new file mode 100644 index 000000000..dab0e1e39 --- /dev/null +++ b/cmd/generate/runnable_call_endpoint_test.go @@ -0,0 +1,313 @@ +package generate + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/codefly-dev/cli/cmd/common" + "github.com/codefly-dev/core/composition" + "github.com/codefly-dev/core/resources" + corerunnable "github.com/codefly-dev/core/runnable" + "github.com/codefly-dev/core/standards" + "github.com/stretchr/testify/require" + googleproto "google.golang.org/protobuf/proto" + "gopkg.in/yaml.v3" +) + +// The bindings a playground generates are read by the run it starts, so they +// resolve in that run's naming scope: the declared one when --naming-scope is +// omitted, the flag's when it is passed, none when it is passed empty — the +// rule `codefly run` applies. --check holds the file to the scope it was +// prepared for. +func TestRunnableBindingsResolveInTheRunsNamingScope(t *testing.T) { + ctx := context.Background() + root, moduleDir := saveRunnableFixture(t, ctx, descriptorSet(t, ingestionFile(conformingOperation())), "0.1.0", connectEndpoint()) + t.Chdir(root) + resetRunnablesFlags(t) + resetRunnableBindingsFlags(t) + require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"documents"})) + publishFixtureConnectEndpoint(t, moduleDir, "connect", nil) + declareLocalNamingScope(t, ctx, root, "alpha") + target := filepath.Join(root, "configurations", "local", RunnableBindingsGroup+".env") + + addresses := map[string]string{} + for _, tc := range []struct { + name, flag, scope string + explicit bool + }{ + {name: "omitted keeps the declared scope", scope: "alpha"}, + {name: "an override replaces it", flag: "beta", explicit: true, scope: "beta"}, + {name: "explicitly empty clears it", flag: "", explicit: true, scope: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + resetRunnableBindingsFlags(t) + if tc.explicit { + require.NoError(t, RunnableBindingsCmd.Flags().Set("naming-scope", tc.flag)) + } + require.NoError(t, RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil)) + want := nativeFixtureAddress(t, ctx, root, "documents", "runtime-worker", "connect", tc.scope) + for _, value := range readPreparedValues(t, target) { + prepared, err := corerunnable.DecodePrepared([]byte(value)) + require.NoError(t, err) + require.Equal(t, want, prepared.GetCall().GetAddress()) + } + addresses[tc.scope] = want + }) + } + require.Len(t, addresses, 3) + require.NotEqual(t, addresses["alpha"], addresses["beta"], "two scopes resolve two addresses from one declaration") + require.NotEqual(t, addresses["alpha"], addresses[""]) + + // The file now holds the unscoped bindings: --check accepts that scope and + // refuses the declared one, naming the command that regenerates it. + resetRunnableBindingsFlags(t) + runnableBindingsCheck = true + require.NoError(t, RunnableBindingsCmd.Flags().Set("naming-scope", "")) + require.NoError(t, RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil)) + resetRunnableBindingsFlags(t) + runnableBindingsCheck = true + err := RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil) + require.ErrorContains(t, err, "out of date") + require.ErrorContains(t, err, "--naming-scope alpha") +} + +// Through a declared call endpoint only the address moves: it is the hop's, +// resolved in the run's scope, while the operation, its Connect procedure, the +// contract, its digest and the policy are exactly the direct binding's. +func TestRunnableBindingsCallThroughTheDeclaredEndpoint(t *testing.T) { + ctx := context.Background() + second := markedMethod("ApplyTextAgain", ".documents.ingest.v1.ApplyTextRequest", ".documents.ingest.v1.ApplyTextResponse", conformingOperation()) + root, moduleDir := saveRunnableFixture(t, ctx, descriptorSet(t, ingestionFile(conformingOperation(), second)), "0.1.0", connectEndpoint()) + t.Chdir(root) + resetRunnablesFlags(t) + resetRunnableBindingsFlags(t) + require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"documents"})) + publishFixtureConnectEndpoint(t, moduleDir, "connect", nil) + addFixtureHop(t, ctx, root) + target := filepath.Join(root, "configurations", "local", RunnableBindingsGroup+".env") + + require.NoError(t, RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil)) + direct := readPreparedValues(t, target) + require.Len(t, direct, 2) + + require.NoError(t, RunnableBindingsCmd.Flags().Set("naming-scope", "rtpg")) + require.NoError(t, RunnableBindingsCmd.Flags().Set("call-endpoint", "authority/hop/http")) + require.NoError(t, RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil)) + through := readPreparedValues(t, target) + require.Len(t, through, len(direct)) + + hop := nativeFixtureAddress(t, ctx, root, "authority", "hop", "http", "rtpg") + owner := nativeFixtureAddress(t, ctx, root, "documents", "runtime-worker", "connect", "rtpg") + require.NotEqual(t, hop, owner) + for key, value := range through { + viaHop, err := corerunnable.DecodePrepared([]byte(value)) + require.NoError(t, err) + straight, err := corerunnable.DecodePrepared([]byte(direct[key])) + require.NoError(t, err) + require.Equal(t, hop, viaHop.GetCall().GetAddress(), "%s is sent to the hop", key) + require.NotEqual(t, straight.GetCall().GetAddress(), viaHop.GetCall().GetAddress()) + require.True(t, googleproto.Equal(straight.GetOperation(), viaHop.GetOperation()), "operation") + require.True(t, googleproto.Equal(straight.GetCall().GetConnect(), viaHop.GetCall().GetConnect()), "procedure") + require.Equal(t, straight.GetOperation().GetSpelling(), viaHop.GetCall().GetConnect().GetProcedure()) + require.True(t, googleproto.Equal(straight.GetContract(), viaHop.GetContract()), "contract") + require.Equal(t, straight.GetContractDigest(), viaHop.GetContractDigest()) + require.True(t, googleproto.Equal(straight.GetPolicy(), viaHop.GetPolicy()), "policy") + } + + // --check holds the file to the endpoint and scope too. + runnableBindingsCheck = true + require.NoError(t, RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil)) + runnableBindingsCallEndpoint = "" + require.ErrorContains(t, RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil), "out of date") +} + +// A hop forwards a call; it serves nothing. An owner that does not serve the +// operation, serves no Connect endpoint, or publishes a stale contract is +// refused exactly as without one, and nothing is written. +func TestRunnableBindingsThroughAHopStillHoldTheOwner(t *testing.T) { + for _, tc := range []struct { + name string + connect bool + publish []string + stale bool + want string + }{ + {name: "no connect endpoint", want: standards.CONNECT}, + {name: "served surface not published", connect: true, want: "no connect endpoint"}, + {name: "lookup not served", connect: true, publish: []string{applyTextMethod}, want: "no connect endpoint"}, + {name: "stale contract catalog", connect: true, publish: []string{applyTextMethod, conformingOperation().LookupMethod}, stale: true, want: "codefly generate contracts"}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + var beside []*resources.Endpoint + if tc.connect { + beside = append(beside, connectEndpoint()) + } + root, moduleDir := saveRunnableFixture(t, ctx, descriptorSet(t, ingestionFile(conformingOperation())), "0.1.0", beside...) + t.Chdir(root) + resetRunnablesFlags(t) + resetRunnableBindingsFlags(t) + require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"documents"})) + if tc.publish != nil { + publishFixtureConnectEndpoint(t, moduleDir, "connect", tc.publish) + } + if tc.stale { + catalog, err := composition.LoadAPIContractCatalog(moduleDir) + require.NoError(t, err) + changed := descriptorSet(t, ingestionFile(conformingOperation(), markedMethod("Extra", ".documents.ingest.v1.ApplyTextRequest", ".documents.ingest.v1.ApplyTextResponse", nil))) + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, filepath.FromSlash(catalog.Endpoints[0].Path)), changed, 0o644)) + } + addFixtureHop(t, ctx, root) + require.NoError(t, RunnableBindingsCmd.Flags().Set("call-endpoint", "authority/hop/http")) + err := RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil) + require.ErrorContains(t, err, tc.want) + require.NoFileExists(t, filepath.Join(root, "configurations", "local", RunnableBindingsGroup+".env")) + }) + } +} + +// A call endpoint is a declared HTTP-based endpoint of a workspace module, or +// generation stops and says which part is wrong. +func TestRunnableBindingsRefuseAnUnusableCallEndpoint(t *testing.T) { + ctx := context.Background() + root, moduleDir := saveRunnableFixture(t, ctx, descriptorSet(t, ingestionFile(conformingOperation())), "0.1.0", connectEndpoint()) + t.Chdir(root) + resetRunnablesFlags(t) + resetRunnableBindingsFlags(t) + require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"documents"})) + publishFixtureConnectEndpoint(t, moduleDir, "connect", nil) + addFixtureHop(t, ctx, root) + for _, tc := range []struct{ coordinate, want string }{ + {"authority/hop", "names //"}, + {"Authority/hop/http", "malformed"}, + {"nowhere/hop/http", "nowhere"}, + {"authority/nothing/http", "nothing"}, + {"authority/hop/missing", "declares no endpoint missing (it declares [grpc http])"}, + {"authority/hop/grpc", "is a grpc endpoint, and a prepared call is sent as HTTP"}, + {"authority/hop/http::grpc", "is a http endpoint"}, + } { + t.Run(tc.coordinate, func(t *testing.T) { + resetRunnableBindingsFlags(t) + require.NoError(t, RunnableBindingsCmd.Flags().Set("call-endpoint", tc.coordinate)) + err := RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil) + require.ErrorContains(t, err, tc.want) + require.NoFileExists(t, filepath.Join(root, "configurations", "local", RunnableBindingsGroup+".env")) + }) + } +} + +// The environment declares where its calls go, so a plain invocation and its +// --check prepare through the hop with no flag; --call-endpoint stays an +// explicit override, and its empty value sends each call to its owner. +func TestAnEnvironmentDeclaresWhereItsRunnableCallsGo(t *testing.T) { + ctx := context.Background() + root, moduleDir := saveRunnableFixture(t, ctx, descriptorSet(t, ingestionFile(conformingOperation())), "0.1.0", connectEndpoint()) + t.Chdir(root) + resetRunnablesFlags(t) + resetRunnableBindingsFlags(t) + require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"documents"})) + publishFixtureConnectEndpoint(t, moduleDir, "connect", nil) + addFixtureHop(t, ctx, root) + declareLocalCallEndpoint(t, ctx, root, "authority/hop/http") + target := filepath.Join(root, "configurations", "local", RunnableBindingsGroup+".env") + addressOf := func() string { + t.Helper() + values := readPreparedValues(t, target) + require.NotEmpty(t, values) + var address string + for _, value := range values { + prepared, err := corerunnable.DecodePrepared([]byte(value)) + require.NoError(t, err) + address = prepared.GetCall().GetAddress() + } + return address + } + + require.NoError(t, RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil)) + hop := nativeFixtureAddress(t, ctx, root, "authority", "hop", "http", "") + require.Equal(t, hop, addressOf(), "the declared endpoint is used with no flag") + runnableBindingsCheck = true + require.NoError(t, RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil), "a plain --check is deterministic against the declaration") + + resetRunnableBindingsFlags(t) + require.NoError(t, RunnableBindingsCmd.Flags().Set("call-endpoint", "")) + require.NoError(t, RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil)) + owner := nativeFixtureAddress(t, ctx, root, "documents", "runtime-worker", "connect", "") + require.Equal(t, owner, addressOf(), "an explicit empty override sends each call to its owner") + + resetRunnableBindingsFlags(t) + runnableBindingsCheck = true + err := RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil) + require.ErrorContains(t, err, "out of date") + require.ErrorContains(t, err, "runnable-bindings --env local`", "the declared endpoint needs no flag to regenerate") +} + +// declareLocalCallEndpoint declares the local environment with a runnable call +// endpoint, as workspace.codefly.yaml spells it. +func declareLocalCallEndpoint(t *testing.T, ctx context.Context, root, endpoint string) { + t.Helper() + workspace, err := resources.LoadWorkspaceFromDir(ctx, root) + require.NoError(t, err) + var value resources.YAMLValue + require.NoError(t, yaml.Unmarshal([]byte(endpoint), &value)) + workspace.Environments = append(workspace.Environments, &resources.Environment{Name: "local", Extensions: map[string]resources.YAMLValue{"runnable-call-endpoint": value}}) + require.NoError(t, workspace.SaveToDirUnsafe(ctx, root)) +} + +// declareLocalNamingScope declares the local environment with a naming scope. +func declareLocalNamingScope(t *testing.T, ctx context.Context, root, scope string) { + t.Helper() + workspace, err := resources.LoadWorkspaceFromDir(ctx, root) + require.NoError(t, err) + workspace.Environments = append(workspace.Environments, &resources.Environment{Name: "local", NamingScope: scope}) + require.NoError(t, workspace.SaveToDirUnsafe(ctx, root)) +} + +// addFixtureHop composes a second module whose hop declares a gRPC face and an +// HTTP face, as the authority's does. It serves and publishes nothing. +func addFixtureHop(t *testing.T, ctx context.Context, root string) { + t.Helper() + workspace, err := resources.LoadWorkspaceFromDir(ctx, root) + require.NoError(t, err) + workspace.Modules = append(workspace.Modules, &resources.ModuleReference{Name: "authority"}) + require.NoError(t, workspace.SaveToDirUnsafe(ctx, root)) + moduleDir := filepath.Join(root, "modules", "authority") + module := &resources.Module{Kind: resources.ModuleKind, Name: "authority", ServiceReferences: []*resources.ServiceReference{{Name: "hop"}}} + module.WithDir(moduleDir) + require.NoError(t, module.Save(ctx)) + service := &resources.Service{ + Name: "hop", + Version: "0.0.1", + Agent: &resources.Agent{Kind: resources.ServiceAgent, Name: "go-grpc", Version: "0.0.1", Publisher: "codefly.dev"}, + Endpoints: []*resources.Endpoint{ + {Name: "grpc", API: standards.GRPC, Visibility: resources.VisibilityPublic, Exposure: resources.ExposureNone}, + {Name: "http", API: standards.HTTP, Visibility: resources.VisibilityPublic, Exposure: resources.ExposureNone}, + }, + } + service.WithDir(filepath.Join(moduleDir, "services", "hop")) + require.NoError(t, service.Save(ctx)) +} + +// nativeFixtureAddress is the address the shared native resolver gives one +// declared endpoint in a naming scope, read from the saved declaration. +func nativeFixtureAddress(t *testing.T, ctx context.Context, root, moduleName, serviceName, endpointName, scope string) string { + t.Helper() + workspace, err := resources.LoadWorkspaceFromDir(ctx, root) + require.NoError(t, err) + module, err := workspace.LoadModuleFromName(ctx, moduleName) + require.NoError(t, err) + service, err := module.LoadServiceFromName(ctx, serviceName) + require.NoError(t, err) + for _, endpoint := range service.Endpoints { + if endpoint.Name == endpointName { + resolved, err := common.ResolveNative(ctx, workspace.Name, moduleName, serviceName, scope, endpoint) + require.NoError(t, err) + require.NotEmpty(t, resolved.Address) + return resolved.Address + } + } + t.Fatalf("%s/%s declares no endpoint %s", moduleName, serviceName, endpointName) + return "" +} diff --git a/cmd/generate/runnables_output.go b/cmd/generate/runnables_output.go index 2e6feb057..4f68299e1 100644 --- a/cmd/generate/runnables_output.go +++ b/cmd/generate/runnables_output.go @@ -12,6 +12,7 @@ import ( "sort" "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/cli/pkg/generators" runnablespkg "github.com/codefly-dev/cli/pkg/runnables" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" corerunnable "github.com/codefly-dev/core/runnable" @@ -161,7 +162,7 @@ func clearGeneratedFiles(output string) error { // whose methods changed without the derived packages being regenerated is a // module publishing operations it no longer implements. func runRunnablesCheck(derived *derivation, output string) error { - existing, err := listRelativeFiles(output) + existing, err := generators.ReadTree(output) if err != nil { return err } diff --git a/cmd/generate/runnables_test.go b/cmd/generate/runnables_test.go index afdeb144b..a007bf0df 100644 --- a/cmd/generate/runnables_test.go +++ b/cmd/generate/runnables_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/Masterminds/semver/v3" + "github.com/codefly-dev/cli/pkg/generators" runnablespkg "github.com/codefly-dev/cli/pkg/runnables" "github.com/codefly-dev/core/composition" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" @@ -603,7 +604,7 @@ func mustRead(t *testing.T, path string) []byte { func snapshotTree(t *testing.T, moduleDir string) map[string][]byte { t.Helper() root := filepath.Join(moduleDir, filepath.FromSlash(runnablespkg.DerivedDir)) - files, err := listRelativeFiles(root) + files, err := generators.ReadTree(root) if err != nil { t.Fatal(err) } diff --git a/cmd/logs.go b/cmd/logs.go index c3c345a01..88b6347c7 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -8,7 +8,6 @@ import ( "io" "os" "path/filepath" - "sort" "strconv" "strings" "time" @@ -28,17 +27,24 @@ var ( logsFile string ) -// LogsCmd shows the codefly CLI session logs (~/.codefly/logs/.log) — the +// LogsCmd shows the codefly CLI session logs (under the Codefly home logs directory) — the // same file the failure hint ("↳ full logs: …") points at. This is the fast // path from "something broke" to "here's what happened" without hunting for the // file or remembering --debug. var LogsCmd = &cobra.Command{ Use: "logs", Short: "Read or follow logs from the current Codefly session", - Long: `Show codefly's detailed session logs (~/.codefly/logs/.log). + Long: `Show codefly's detailed session logs (under the Codefly home logs directory). -By default prints the last 100 lines of today's log, pretty-printed. This is the -log the failure hint points at, so the usual flow after a command fails is: +By default prints the last 100 lines from the latest-started invocation, +pretty-printed. Each invocation owns a separate directory; late shutdown records +from an older process stay in its files. The failure hint names the exact file. +CODEFLY_HOME (or --plugin-path) controls the log root. Old daily logs remain +readable, and are the default fallback when no invocation logs exist. + +Follow stays on the selected file, including across later runs; it does not +switch files on rotation. Use --file to select a known invocation segment. +The usual flow after a command fails is: codefly logs # what just happened codefly logs -f # follow live (e.g. in a second terminal during a run) @@ -127,32 +133,38 @@ log the failure hint points at, so the usual flow after a command fails is: }, } -// resolveLogFile returns today's log file, falling back to the most recent -// *.log in the logs dir. +// resolveLogFile selects the latest-started invocation, never the last writer. +// Follow retains the selected descriptor; --file selects an explicit segment. +// Legacy daily logs remain the fallback when no invocation log exists. func resolveLogFile() (string, error) { dir := cli.LogsDir() - if dir == "" { - return "", fmt.Errorf("cannot resolve logs directory") - } - today := filepath.Join(dir, time.Now().Format("2006-01-02")+".log") - if _, err := os.Stat(today); err == nil { - return today, nil - } entries, err := os.ReadDir(dir) if err != nil { return "", fmt.Errorf("no logs found in %s (run a codefly command first)", dir) } - var logs []string - for _, e := range entries { - if !e.IsDir() && strings.HasSuffix(e.Name(), ".log") { - logs = append(logs, e.Name()) + // ReadDir sorts by name. A fixed invocation prefix keeps a late write or + // rotation in an older process from becoming the newest invocation. + for i := len(entries) - 1; i >= 0; i-- { + e := entries[i] + if !e.IsDir() || !strings.HasPrefix(e.Name(), "run-") { + continue + } + segments, err := os.ReadDir(filepath.Join(dir, e.Name())) + if err != nil { + return "", fmt.Errorf("read invocation logs: %w", err) + } + for j := len(segments) - 1; j >= 0; j-- { + if !segments[j].IsDir() && strings.HasSuffix(segments[j].Name(), ".log") { + return filepath.Join(dir, e.Name(), segments[j].Name()), nil + } } } - if len(logs) == 0 { - return "", fmt.Errorf("no logs found in %s (run a codefly command first)", dir) + for i := len(entries) - 1; i >= 0; i-- { + if !entries[i].IsDir() && strings.HasSuffix(entries[i].Name(), ".log") { + return filepath.Join(dir, entries[i].Name()), nil + } } - sort.Strings(logs) // date-prefixed names sort chronologically - return filepath.Join(dir, logs[len(logs)-1]), nil + return "", fmt.Errorf("no logs found in %s (run a codefly command first)", dir) } // tailLines returns the last n lines of a file (all lines when n <= 0). @@ -225,5 +237,5 @@ func init() { LogsCmd.Flags().IntVarP(&logsTail, "tail", "n", 100, "Show the last N lines (0 = all)") LogsCmd.Flags().BoolVar(&logsRaw, "raw", false, "Print raw JSON lines (for jq/grep)") LogsCmd.Flags().BoolVar(&logsPath, "path", false, "Print only the log file path") - LogsCmd.Flags().StringVar(&logsFile, "file", "", "Read a specific log file instead of today's") + LogsCmd.Flags().StringVar(&logsFile, "file", "", "Read a specific log file instead of the latest invocation segment") } diff --git a/cmd/logs_test.go b/cmd/logs_test.go new file mode 100644 index 000000000..3afb4f8bc --- /dev/null +++ b/cmd/logs_test.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/core/wool" + "github.com/stretchr/testify/require" +) + +func TestLogSelectionKeepsTheLatestInvocationDespiteOlderWritesAndRotation(t *testing.T) { + t.Setenv("CODEFLY_HOME", t.TempDir()) + root := cli.LogsDir() + require.NoError(t, os.MkdirAll(root, 0o700)) + legacy := filepath.Join(root, "9999-12-31.log") + require.NoError(t, os.WriteFile(legacy, []byte("legacy"), 0o600)) + old, err := cli.NewFileLogger(root) + require.NoError(t, err) + defer old.Close() + fresh, err := cli.NewFileLogger(root) + require.NoError(t, err) + defer fresh.Close() + fresh.Process(&wool.Log{Level: wool.INFO, Message: "fresh startup"}) + old.Process(&wool.Log{Level: wool.INFO, Message: "old shutdown"}) + // A later old-run segment must never win over the newer invocation. + require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(old.Path()), "9999-12-31-999999.log"), []byte("old rotated"), 0o600)) + got, err := resolveLogFile() + require.NoError(t, err) + require.Equal(t, fresh.Path(), got) + latestSegment := filepath.Join(filepath.Dir(fresh.Path()), "9999-12-31-999999.log") + require.NoError(t, os.WriteFile(latestSegment, []byte("fresh rotated"), 0o600)) + got, err = resolveLogFile() + require.NoError(t, err) + require.Equal(t, latestSegment, got) + // Explicit paths and previously opened descriptors remain usable. + require.Equal(t, []string{"legacy"}, tailLines(legacy, 10)) + file, err := os.Open(fresh.Path()) + require.NoError(t, err) + defer file.Close() + before, err := file.Stat() + require.NoError(t, err) + fresh.Process(&wool.Log{Level: wool.INFO, Message: "fresh still writing"}) + after, err := file.Stat() + require.NoError(t, err) + require.Greater(t, after.Size(), before.Size()) +} + +func TestLogSelectionReadsLegacyFilesWithoutCreatingAnInvocation(t *testing.T) { + t.Setenv("CODEFLY_HOME", t.TempDir()) + _, err := resolveLogFile() + require.ErrorContains(t, err, "no logs found") + require.NoError(t, os.MkdirAll(cli.LogsDir(), 0o700)) + legacy := filepath.Join(cli.LogsDir(), "2026-01-01.log") + require.NoError(t, os.WriteFile(legacy, []byte("legacy"), 0o600)) + got, err := resolveLogFile() + require.NoError(t, err) + require.Equal(t, legacy, got) + entries, err := os.ReadDir(cli.LogsDir()) + require.NoError(t, err) + require.Len(t, entries, 1) +} diff --git a/cmd/run/artifact_cache_test.go b/cmd/run/artifact_cache_test.go index e111d4ddb..35990150a 100644 --- a/cmd/run/artifact_cache_test.go +++ b/cmd/run/artifact_cache_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "github.com/codefly-dev/cli/pkg/composition" @@ -44,26 +45,63 @@ func TestArtifactCacheGroupCreatesTheCacheRoot(t *testing.T) { require.True(t, info.IsDir()) } -// A component that declares a page and built no bundle is refused by name, -// with what to build; a headless component needs none, and a leftover dist -// is not its page. -func TestANativeRunRefusesADeclaredPageWithoutItsBuiltBundle(t *testing.T) { +// A declared frontend is built before delivery, even with a leftover dist. +// The controlled Docker peer exercises the CLI process boundary without a +// released agent, npm registry or provider toolchain in this gate. +func TestANativeRunBuildsItsDeclaredPageBeforeRetainingIt(t *testing.T) { ctx := context.Background() module := &resources.Module{Name: "wiki", ServiceEntry: "api"} module.WithDir(t.TempDir()) entry := filepath.Join(module.ServicesDir(), "api") require.NoError(t, os.MkdirAll(entry, 0o755)) - cluster := &launcher{workspace: &resources.Workspace{Name: "acme"}} + workspace := &resources.Workspace{Name: "acme"} + workspace.WithDir(t.TempDir()) + cluster := &launcher{workspace: workspace} bundle, err := cluster.frontendBundle(ctx, module) - require.NoError(t, err, "headless: no page, no bundle needed") + require.NoError(t, err, "headless: no build needed") require.Nil(t, bundle) require.NoError(t, os.WriteFile(filepath.Join(entry, solutionrun.FrontendFileName), []byte("title: Wiki\norder: 1\nexposed_module: ./Page\nhost_contract: 1\n"), 0o600)) - _, err = cluster.frontendBundle(ctx, module) - require.ErrorContains(t, err, "its bundle is not built") - require.ErrorContains(t, err, filepath.Join(module.ServicesDir(), "fe-remote"), "the source to build is named") - require.ErrorContains(t, err, solutionrun.FrontendBundleManifest) + remote := filepath.Join(module.ServicesDir(), "fe-remote") + require.NoError(t, os.MkdirAll(remote, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(remote, "package.json"), []byte(`{"scripts":{"build":"owner-build"}}`), 0o600)) + bin := t.TempDir() + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + tool := filepath.Join(bin, "docker") + writeTool := func(body string) { require.NoError(t, os.WriteFile(tool, []byte("#!/bin/sh\nset -eu\n"+body), 0o700)) } + writeTool(`recipe=$(cat) +case "$recipe" in *'npm ci && npm run build'*) ;; *) echo 'missing owner build' >&2; exit 2;; esac +for arg in "$@"; do + case "$arg" in type=local,dest=*) destination=${arg#type=local,dest=};; esac + source=$arg +done +[ ! -d "$source/services/fe-remote/dist" ] +mkdir -p "$destination" +echo '{"name":"built-wiki"}' > "$destination/mf-manifest.json" +`) + bundle, err = cluster.frontendBundle(ctx, module) + require.NoError(t, err) + require.NotNil(t, bundle) + archive := filepath.Join(composition.ArtifactCacheDir(workspace.Dir()), strings.TrimPrefix(string(bundle.Digest), "sha256:")) + contents, err := os.ReadFile(archive) + require.NoError(t, err) + require.Contains(t, string(contents), "built-wiki") + require.NotContains(t, string(contents), "stale") + + require.NoError(t, os.MkdirAll(filepath.Join(remote, "dist"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(remote, "dist", solutionrun.FrontendBundleManifest), []byte("stale"), 0o600)) + writeTool("cat >/dev/null\necho 'owner bundler: Page.tsx failed' >&2\nexit 7\n") + bundle, err = cluster.frontendBundle(ctx, module) + require.Nil(t, bundle, "a failed build never delivers the old dist or cached payload") + require.ErrorContains(t, err, "frontend of wiki") + require.ErrorContains(t, err, "owner bundler: Page.tsx failed") + + writeTool("cat >/dev/null\necho 'owner build returned without artifacts'\n") + bundle, err = cluster.frontendBundle(ctx, module) + require.Nil(t, bundle) + require.ErrorContains(t, err, "frontend build of wiki wrote no regular mf-manifest.json") + require.ErrorContains(t, err, "owner build returned without artifacts") } // Forgetting a stopped workload drops what this run kept for it, and only diff --git a/cmd/run/cluster.go b/cmd/run/cluster.go index 8c2259c45..cad24929c 100644 --- a/cmd/run/cluster.go +++ b/cmd/run/cluster.go @@ -90,6 +90,9 @@ func runCluster(modules []string) error { modules = append(modules, reference.Name) } } + if err = gitops.CheckCompositionPermissions(ctx, workspace, env, true, modules...); err != nil { + return err + } plane, err := control.NewAt(workspace.Dir()) if err != nil { return err diff --git a/cmd/run/extend.go b/cmd/run/extend.go index d71466144..eb71ab584 100644 --- a/cmd/run/extend.go +++ b/cmd/run/extend.go @@ -167,6 +167,9 @@ func extendRunningWorkspace(ctx context.Context, flow *orchestration.Flow, basel known[root] = true } fresh := freshRoots(known, roots) + if err = flow.CheckComposition(ctx, workspace, fresh...); err != nil { + return err + } merged := solutionrun.RunInputs{Overrides: derivedOverrides} for _, root := range fresh { info, parseErr := resources.ParseServiceWithOptionalModule(root) diff --git a/cmd/run/extend_test.go b/cmd/run/extend_test.go index bf5390303..ac2557ebe 100644 --- a/cmd/run/extend_test.go +++ b/cmd/run/extend_test.go @@ -1,11 +1,15 @@ package run import ( + "context" + "errors" "os" "path/filepath" "strings" "testing" + "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/cli/pkg/gitops" "github.com/codefly-dev/cli/pkg/orchestration" "github.com/codefly-dev/core/resources" @@ -129,3 +133,44 @@ func TestWithdrawalRetriesAfterTheRootHasLeftTheFlow(t *testing.T) { require.NoError(t, withdrawDroppedRoots(ctx, flow, pinnedRoots(flow))) require.Empty(t, cluster.pendingWithdrawals) } + +func TestRefreshPreflightKeepsBaselineRootsOutAndPrecedesAdmission(t *testing.T) { + ctx := t.Context() + workspace, env := writeHostedNativeWorkspace(t) + module, err := workspace.LoadModuleFromName(ctx, "model") + require.NoError(t, err) + service, err := module.LoadServiceFromName(ctx, "model") + require.NoError(t, err) + refusal := errors.New("invalid refreshed permissions") + reject := false + checks := 0 + check := func(_ context.Context, selected *resources.Workspace, _ *environments.Environment) error { + checks++ + for _, ref := range selected.Modules { + require.NotEqual(t, "ledger", ref.Name, "baseline-only roots must stay outside this run") + } + if reject { + return refusal + } + return nil + } + flow, err := orchestration.NewFlow(ctx, workspace, module, service, env, orchestration.RunMode, orchestration.WithRunModuleClosure("model"), orchestration.WithCompositionCheck(check)) + require.NoError(t, err) + baseline := extensionBaseline(ctx, workspace) + cluster := &launcher{workspace: workspace, env: env} + launcherMu.Lock() + previous := launcherActive + launcherActive = cluster + launcherMu.Unlock() + t.Cleanup(func() { launcherMu.Lock(); launcherActive = previous; launcherMu.Unlock() }) + t.Chdir(workspace.Dir()) + reject = true + require.ErrorIs(t, extendRunningWorkspace(ctx, flow, baseline), refusal, "even a no-new-root refresh is checked") + require.Equal(t, 2, checks) + require.Same(t, workspace, cluster.workspace, "admission did not change the launcher snapshot") + composeModule(t, workspace, "newcomer") + require.ErrorIs(t, extendRunningWorkspace(ctx, flow, baseline), refusal) + require.Equal(t, 3, checks) + require.Same(t, workspace, cluster.workspace) + require.Equal(t, []string{"model/model"}, flow.Roots()) +} diff --git a/cmd/run/generated_secrets.go b/cmd/run/generated_secrets.go new file mode 100644 index 000000000..2c583e225 --- /dev/null +++ b/cmd/run/generated_secrets.go @@ -0,0 +1,104 @@ +package run + +import ( + "context" + "fmt" + "strings" + + "github.com/codefly-dev/core/resources" + + "github.com/codefly-dev/cli/cmd/config" + "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/cli/pkg/secretgen" +) + +// provisionGeneratedSecrets writes, before a local run loads its +// configuration, every key the environment's service-secrets generators +// declare that the workspace's secret configuration does not hold yet: what +// `codefly deploy secrets` does for a cell's store, minted by the same +// generator, into the files `codefly config generate` writes. A key that holds +// a value is never rotated. An environment whose service-secrets names a store +// keeps its secrets there, so nothing is written for it locally. +func provisionGeneratedSecrets(ctx context.Context, workspace *resources.Workspace, env *environments.Environment) ([]string, error) { + secrets := env.ServiceSecrets + if secrets == nil || secrets.Stored() || len(secrets.Generate) == 0 { + return nil, nil + } + configProfile, err := env.ConfigurationProfileName() + if err != nil { + return nil, err + } + var written []string + for index := range secrets.Generate { + generator := &secrets.Generate[index] + scopes, err := generatorScopes(ctx, workspace, generator) + if err != nil { + return nil, fmt.Errorf("service-secrets generate[%d]: %w", index, err) + } + format, size := generator.Encoding() + for _, scope := range scopes { + target := config.Target{ScopeDir: scope, Profile: configProfile, Name: generator.Configuration, Secret: true} + keys, err := generateMissing(target, generator.Keys, format, size) + if err != nil { + return nil, fmt.Errorf("service-secrets generate[%d] %s: %w", index, generator.Configuration, err) + } + for _, key := range keys { + written = append(written, generator.Configuration+"/"+key) + } + } + } + return written, nil +} + +// generatorScopes is the directory each of the generator's configurations +// lives under: the workspace's, or each named service's. +func generatorScopes(ctx context.Context, workspace *resources.Workspace, generator *environments.EnvironmentSecretGenerator) ([]string, error) { + if generator.Scope == environments.SecretGeneratorScopeWorkspace { + return []string{workspace.Dir()}, nil + } + if len(generator.Services) == 0 { + return nil, fmt.Errorf("a local run generates a service configuration only for the services the generator names") + } + scopes := make([]string, 0, len(generator.Services)) + for _, unique := range generator.Services { + moduleName, serviceName := resources.SplitUnique(unique) + module, err := workspace.LoadModuleFromName(ctx, moduleName) + if err != nil { + return nil, err + } + service, err := module.LoadServiceFromName(ctx, serviceName) + if err != nil { + return nil, err + } + scopes = append(scopes, service.Dir()) + } + return scopes, nil +} + +// generateMissing sets every key the target does not hold to a fresh value and +// returns the keys it set. +func generateMissing(target config.Target, keys []string, format secretgen.Format, size int) ([]string, error) { + doc, err := config.Load(target) + if err != nil { + return nil, err + } + var written []string + for _, key := range keys { + key = strings.TrimSpace(key) + if doc.Has(key) { + continue + } + value, err := secretgen.Generate(format, size) + if err != nil { + return nil, err + } + if err := doc.Set(key, value); err != nil { + return nil, err + } + written = append(written, key) + } + if len(written) == 0 { + return nil, nil + } + return written, config.Write(target, doc) +} diff --git a/cmd/run/generated_secrets_test.go b/cmd/run/generated_secrets_test.go new file mode 100644 index 000000000..7a8d5eb7b --- /dev/null +++ b/cmd/run/generated_secrets_test.go @@ -0,0 +1,88 @@ +package run + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" + + "github.com/codefly-dev/cli/pkg/environments" +) + +func generatedSecretsWorkspace(t *testing.T) *resources.Workspace { + t.Helper() + dir := t.TempDir() + files := map[string]string{ + ".gitignore": "**/configurations/local/*.secret.env\n", + "workspace.codefly.yaml": "name: secrets-fixture\nlayout: modules\nenvironments:\n - name: local\n", + } + for name, content := range files { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)) + } + cmd := exec.Command("git", "init", "-q") + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git init: %s", out) + workspace, err := resources.LoadWorkspaceFromDir(context.Background(), dir) + require.NoError(t, err) + return workspace +} + +// A local run generates what its environment's service-secrets declare and the +// workspace's secret configuration does not hold yet, into the file +// `codefly config generate` writes, and never rotates a held value. +func TestALocalRunGeneratesTheSecretsItsEnvironmentDeclares(t *testing.T) { + workspace := generatedSecretsWorkspace(t) + env := &environments.Environment{Name: "local", ServiceSecrets: &environments.EnvironmentServiceSecrets{ + Generate: []environments.EnvironmentSecretGenerator{ + {Scope: environments.SecretGeneratorScopeWorkspace, Configuration: "hop", Keys: []string{"PROOF"}}, + {Scope: environments.SecretGeneratorScopeWorkspace, Configuration: "cursors", Keys: []string{"KEY"}, Format: "base64", Bytes: 16}, + }, + }} + written, err := provisionGeneratedSecrets(context.Background(), workspace, env) + require.NoError(t, err) + require.Equal(t, []string{"hop/PROOF", "cursors/KEY"}, written) + + path := filepath.Join(workspace.Dir(), "configurations", "local", "hop.secret.env") + first, err := os.ReadFile(path) + require.NoError(t, err) + require.Regexp(t, `^PROOF=[0-9a-f]{64}\n?$`, string(first)) + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + + written, err = provisionGeneratedSecrets(context.Background(), workspace, env) + require.NoError(t, err) + require.Empty(t, written, "a held value is kept") + again, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, string(first), string(again)) +} + +// An environment whose service-secrets names a store keeps its secrets there: +// a run writes nothing for it, and a service-scope generator naming no service +// is refused rather than guessed. +func TestALocalRunWritesNothingForAStoreAndGuessesNoService(t *testing.T) { + workspace := generatedSecretsWorkspace(t) + stored := &environments.Environment{Name: "local", ServiceSecrets: &environments.EnvironmentServiceSecrets{ + SecretStore: environments.EnvironmentSecretStoreReference{Name: "cell", Kind: "ClusterSecretStore"}, + Generate: []environments.EnvironmentSecretGenerator{{Scope: environments.SecretGeneratorScopeWorkspace, Configuration: "hop", Keys: []string{"PROOF"}}}, + }} + written, err := provisionGeneratedSecrets(context.Background(), workspace, stored) + require.NoError(t, err) + require.Empty(t, written) + _, err = os.Stat(filepath.Join(workspace.Dir(), "configurations")) + require.True(t, os.IsNotExist(err)) + + unnamed := &environments.Environment{Name: "local", ServiceSecrets: &environments.EnvironmentServiceSecrets{ + Generate: []environments.EnvironmentSecretGenerator{{Scope: environments.SecretGeneratorScopeService, Configuration: "signing", Keys: []string{"SEED"}}}, + }} + _, err = provisionGeneratedSecrets(context.Background(), workspace, unnamed) + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "only for the services the generator names"), err.Error()) +} diff --git a/cmd/run/launcher.go b/cmd/run/launcher.go index 868c0979f..0bf366776 100644 --- a/cmd/run/launcher.go +++ b/cmd/run/launcher.go @@ -3,6 +3,11 @@ package run import ( "context" "fmt" + + "golang.org/x/sync/semaphore" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "os" "path/filepath" "slices" @@ -11,6 +16,9 @@ import ( "sync" "time" + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" + "github.com/codefly-dev/cli/pkg/composition" "github.com/codefly-dev/cli/pkg/delivery/signing" "github.com/codefly-dev/cli/pkg/environments" @@ -79,8 +87,12 @@ type launcher struct { // running are the identity workloads that reached Running: the ones a // module's manifest lists. running map[string]bool + // delivery serializes manifest generations and network calls without + // holding mu. A canceled waiter must not wait for another host call. + deliveryOnce sync.Once + delivery *semaphore.Weighted // delivered is the last manifest delivered per module, a withdrawal - // included. + // included; protected by delivery rather than the short-lived state lock. delivered map[string]*solutionhost.ModuleManifest // pendingWithdrawals are modules whose withdrawal the host has not applied // yet: retried on the next composition refresh, even with no gone roots. @@ -154,15 +166,20 @@ func attachLauncher(ctx context.Context, flow *orchestration.Flow, workspace *re world := flow.World() world.RenderOwnedGroups = cluster.renderOwnedGroups world.RuntimeOverrides = cluster.runtimeOverrides - flow.WithRunningHook(func(service string, _ int) { - go cluster.onRunning(context.WithoutCancel(ctx), service) - }) - go cluster.refreshTokens() + cluster.registerRunningHook(ctx, flow.WithRunningHook) + go cluster.refreshTokens(ctx) w.Info("native run: the run vouches for the composition's workloads", wool.Field("anchor", launcherIdentity.AnchorFingerprint()[:16])) launcherActive = cluster return nil } +// registerRunningHook binds delivery to the lifetime of this native run. +func (cluster *launcher) registerRunningHook(ctx context.Context, register func(func(string, int))) { + register(func(service string, _ int) { + go cluster.onRunning(ctx, service) + }) +} + func stopLauncher() { launcherMu.Lock() defer launcherMu.Unlock() @@ -417,27 +434,22 @@ func artifactCacheGroup(workspaceDir string) (*basev0.Configuration, error) { }}}, nil } -// frontendBundle archives the bundle a component's frontend build wrote -// beside its entry service, in core's canonical form, and retains it in the -// workspace's artifact cache under its digest — the read-only source the -// host's native adapter reads bundles from — so the manifest names the bytes -// the host verifies. A component that declares a page and built no bundle is -// refused by name, with what to build: a host admits no new frontend without -// its signed payload, and a mutable fallback is never delivered in its place. -// A headless component needs no bundle, and a leftover one is not its page. +// frontendBundle builds a declared page through the owner's npm workflow, then +// retains its canonical archive in the existing artifact cache for host delivery. +// Headless components need no build; a leftover dist is never a serving claim. func (cluster *launcher) frontendBundle(ctx context.Context, module *resources.Module) (*solutionhost.FrontendPayload, error) { frontend, err := solutionrun.DeclaredFrontend(ctx, module) if err != nil || frontend == nil { return nil, err } - root, built, err := solutionrun.FrontendBundle(module) + scratch, err := os.MkdirTemp("", "codefly-run-frontend-") if err != nil { - return nil, err + return nil, fmt.Errorf("prepare the frontend build of %s: %w", module.Name, err) } - if !built { - source := filepath.Join(module.ServicesDir(), "fe-remote") - return nil, fmt.Errorf("module %s declares a frontend (%s beside its service-entry) and its bundle is not built: %s holds no %s; a host admits no frontend without its signed bundle, so build the frontend from %s with its own build workflow and start the run again", - module.Name, solutionrun.FrontendFileName, filepath.Join(module.ServicesDir(), filepath.FromSlash(solutionrun.FrontendBundleDir)), solutionrun.FrontendBundleManifest, source) + defer func() { _ = os.RemoveAll(scratch) }() + root := filepath.Join(scratch, "bundle") + if err = solutionrun.BuildLocalFrontendBundle(ctx, module, root); err != nil { + return nil, err } digest, _, err := composition.RetainCanonicalArchive(ctx, cluster.composed().Dir(), root) if err != nil { @@ -473,19 +485,21 @@ func (cluster *launcher) project(unique string, identity gitops.NativeIdentity) return cluster.vouch.Project(unique, identity.Workload(cluster.env.Host.TrustDomain)) } -// refreshTokens re-issues every projected token at 80% of its lifetime, as -// the kubelet rotates a projected token: a mint client that re-reads the -// projection before every mint always presents a token the host has not -// recorded yet. -func (cluster *launcher) refreshTokens() { - ticker := time.NewTicker(identity.RefreshInterval) +// refreshTokens checks wall-clock expiry on a short poll, so waking from +// suspend does not leave expired credentials waiting for a lifetime timer. +func (cluster *launcher) refreshTokens(ctx context.Context) { + ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for { select { + case <-ctx.Done(): + return case <-cluster.stop: return case <-ticker.C: - cluster.vouch.Refresh() + if err := cluster.vouch.Refresh(time.Now()); err != nil { + wool.Get(ctx).In("launcher.refreshTokens").Error("cannot refresh workload credentials; retrying on next poll", wool.ErrField(err)) + } } } } @@ -523,7 +537,7 @@ func (cluster *launcher) onRunning(ctx context.Context, unique string) { return } if err != nil { - w.Error(fmt.Sprintf("delivery to the host failed: module %s's manifest does not list %s, so every gateway route to module %s answers 404 until it is delivered (a restart of the workload delivers again)", identity.Module, unique, identity.Module), wool.ErrField(err)) + w.Error(fmt.Sprintf("delivery completion was not confirmed for module %s, workload %s; routes to module %s may be unavailable (the host may already have applied the manifest)", identity.Module, unique, identity.Module), wool.ErrField(err)) } } @@ -532,6 +546,51 @@ func (cluster *launcher) onRunning(ctx context.Context, unique string) { // state, and the delivery account's credential. It also answers the gateway's // current address, which the envelope follows. func (cluster *launcher) newDeliverer(ctx context.Context) (*gitops.NativeDeliverer, string, error) { + deliveryEndpoint := strings.Split(cluster.env.Host.Delivery, "/")[2] + deliveryURL, err := cluster.flow.GetAddressForEndpoint(ctx, cluster.hostModule, cluster.hostService, deliveryEndpoint) + if err != nil { + return nil, "", fmt.Errorf("the host's delivery endpoint is unresolved: %w", err) + } + // A host that implements codefly.dev/composition receives manifests through + // ApplyManifest and holds the manifest as the ceiling: no envelope, no + // gateway, nothing of the host known here but the interface it declares. + if gitops.HostImplements(ctx, cluster.composed(), cluster.hostModule, cluster.hostService, deliveryEndpoint, gitops.CompositionInterface) { + conn, dialErr := grpc.NewClient(strings.TrimPrefix(strings.TrimPrefix(deliveryURL, "http://"), "grpc://"), grpc.WithTransportCredentials(insecure.NewCredentials())) + if dialErr != nil { + return nil, "", fmt.Errorf("dial the host's composition interface: %w", dialErr) + } + composition := gitops.NewCompositionDelivery(conn, cluster.signer, + func(ctx context.Context, module string) ([]*compositionv1.MethodPolicyV1, bool, error) { + m, loadErr := cluster.composed().LoadModuleFromName(ctx, module) + if loadErr != nil { + return nil, false, loadErr + } + return gitops.MethodPolicies(m.Dir()) + }, + func(ctx context.Context, module string) ([]*authorityv1.WorkScopeV1, error) { + m, loadErr := cluster.composed().LoadModuleFromName(ctx, module) + if loadErr != nil { + return nil, loadErr + } + return gitops.ScopeCeilings(m.Dir()) + }, + func(ctx context.Context, module string) ([]*compositionv1.RoleV1, error) { + m, loadErr := cluster.composed().LoadModuleFromName(ctx, module) + if loadErr != nil { + return nil, loadErr + } + return gitops.Roles(m.Dir()) + }) + return &gitops.NativeDeliverer{ + Workspace: cluster.composed(), Env: cluster.env, Signer: cluster.signer, + StateDir: filepath.Join(cluster.stateDir, "delivery"), DeliveryURL: deliveryURL, + Token: func(module string) (string, error) { + return cluster.vouch.Issue(cluster.vouch.Workload(module, workloadidentity.DeliveryService)) + }, + ObserverToken: func() (string, error) { return cluster.vouch.Issue(cluster.vouch.Observer()) }, + Prepare: composition.Prepare, Applied: composition.Applied, + }, "", nil + } gatewayModule, gatewayService, ok := strings.Cut(cluster.env.Host.Gateway, "/") if !ok { return nil, "", fmt.Errorf("a native run writes the envelope through the host gateway, and host.gateway %q names no /", cluster.env.Host.Gateway) @@ -540,10 +599,6 @@ func (cluster *launcher) newDeliverer(ctx context.Context) (*gitops.NativeDelive if err != nil { return nil, "", fmt.Errorf("the host gateway's rest endpoint is unresolved: %w", err) } - deliveryURL, err := cluster.flow.GetAddressForEndpoint(ctx, cluster.hostModule, cluster.hostService, strings.Split(cluster.env.Host.Delivery, "/")[2]) - if err != nil { - return nil, "", fmt.Errorf("the host's delivery endpoint is unresolved: %w", err) - } return &gitops.NativeDeliverer{ Workspace: cluster.composed(), Env: cluster.env, Signer: cluster.signer, StateDir: filepath.Join(cluster.stateDir, "delivery"), DeliveryURL: deliveryURL, @@ -605,14 +660,19 @@ func (cluster *launcher) withdraw(ctx context.Context, uniques []string) ([]stri // settles to the same generation, which the host replays. A module this run // never delivered has nothing to withdraw. func (cluster *launcher) withdrawModule(ctx context.Context, module string) (bool, error) { - cluster.mu.Lock() - defer cluster.mu.Unlock() + if err := cluster.acquireDelivery(ctx); err != nil { + return false, err + } + defer cluster.delivery.Release(1) delivered := cluster.delivered[module] + cluster.mu.Lock() if delivered == nil { delete(cluster.pendingWithdrawals, module) + cluster.mu.Unlock() return false, nil } cluster.pendingWithdrawals[module] = true + cluster.mu.Unlock() withdrawal, err := gitops.WithdrawalOf(delivered) if err != nil { return false, err @@ -631,7 +691,9 @@ func (cluster *launcher) withdrawModule(ctx context.Context, module string) (boo if err := deliverer.WaitForWithdrawal(ctx, withdrawal); err != nil { return false, err } + cluster.mu.Lock() delete(cluster.pendingWithdrawals, module) + cluster.mu.Unlock() return true, nil } @@ -683,9 +745,11 @@ func (cluster *launcher) startedIdentities(module string) []gitops.NativeIdentit // that runs now, with the addresses this run allocated for its routed // endpoints, after making sure the host holds the composition's envelope. func (cluster *launcher) deliver(ctx context.Context, module string) error { + if err := cluster.acquireDelivery(ctx); err != nil { + return err + } + defer cluster.delivery.Release(1) started := cluster.startedIdentities(module) - cluster.mu.Lock() - defer cluster.mu.Unlock() if len(started) == 0 { return nil } @@ -705,8 +769,12 @@ func (cluster *launcher) deliver(ctx context.Context, module string) error { if err != nil { return err } - if err = cluster.ensureEnvelope(ctx, gatewayURL); err != nil { - return err + // A host reached through the composition interface holds the manifest as the + // ceiling and takes no envelope. + if gatewayURL != "" { + if err = cluster.ensureEnvelope(ctx, gatewayURL); err != nil { + return err + } } bundle, err := cluster.frontendBundle(ctx, loaded) if err != nil { @@ -735,6 +803,11 @@ func (cluster *launcher) deliver(ctx context.Context, module string) error { return nil } +func (cluster *launcher) acquireDelivery(ctx context.Context) error { + cluster.deliveryOnce.Do(func() { cluster.delivery = semaphore.NewWeighted(1) }) + return cluster.delivery.Acquire(ctx, 1) +} + // ensureEnvelope writes the composition's envelope — each module principal's // grant, as configurations//module-envelope.json states it — as // the fixture's platform administrator, unless the host holds it already. A diff --git a/cmd/run/launcher_refresh_test.go b/cmd/run/launcher_refresh_test.go new file mode 100644 index 000000000..13da31e52 --- /dev/null +++ b/cmd/run/launcher_refresh_test.go @@ -0,0 +1,73 @@ +package run + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/codefly-dev/cli/pkg/run/identity" + "github.com/codefly-dev/core/wool" + "github.com/codefly-dev/core/workloadidentity/native" + "github.com/stretchr/testify/require" +) + +type refreshLogCapture struct{ messages []string } + +func (capture *refreshLogCapture) Process(log *wool.Log) { + data, _ := json.Marshal(log) + capture.messages = append(capture.messages, string(data)) +} + +func TestRefreshTokensLogsFailureAndRetriesOnNextPoll(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + vouch, err := identity.Start("example.test", "host", t.TempDir()) + require.NoError(t, err) + workload := vouch.Workload("example", "scorer") + vouch.Declare(workload) + credential, err := vouch.Project(workload.Unique(), workload) + require.NoError(t, err) + require.NoError(t, os.Remove(credential.File)) + require.NoError(t, os.Mkdir(credential.File, 0o700)) + + capture := &refreshLogCapture{} + provider := wool.New(context.Background(), &wool.Resource{}).WithLogger(capture) + defer provider.Done() + ctx, cancel := context.WithCancel(provider.Inject(context.Background())) + defer cancel() + cluster := &launcher{vouch: vouch, stop: make(chan struct{})} + done := make(chan struct{}) + go func() { defer close(done); cluster.refreshTokens(ctx) }() + synctest.Wait() + time.Sleep(native.RefreshInterval) + synctest.Wait() + logs := strings.Join(capture.messages, "\n") + require.Contains(t, logs, "cannot refresh workload credentials") + require.Contains(t, logs, workload.Unique()) + err = vouch.Refresh(time.Now()) + require.Error(t, err) + var filesystemError *os.LinkError + require.True(t, errors.As(err, &filesystemError)) + require.Contains(t, logs, filesystemError.Err.Error()) + + // Repair the failed projection: waiting another 480 seconds would leave + // an expired credential in place. The next short poll must retry it. + require.NoError(t, os.Remove(credential.File)) + time.Sleep(5 * time.Second) + synctest.Wait() + info, err := os.Stat(credential.File) + require.NoError(t, err) + require.True(t, info.Mode().IsRegular()) + cancel() + synctest.Wait() + select { + case <-done: + default: + t.Fatal("credential polling outlived the run context") + } + }) +} diff --git a/cmd/run/native_delivery_cancellation_test.go b/cmd/run/native_delivery_cancellation_test.go new file mode 100644 index 000000000..617af2d52 --- /dev/null +++ b/cmd/run/native_delivery_cancellation_test.go @@ -0,0 +1,222 @@ +package run + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/codefly-dev/cli/pkg/orchestration" + "github.com/stretchr/testify/require" + "sync/atomic" +) + +func TestNativeDeliveryDoesNotStartAfterCancellation(t *testing.T) { + for _, reason := range []string{"parent", "stop", "superseded"} { + t.Run(reason, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + stop, superseded := make(chan struct{}), make(chan struct{}) + want := context.Canceled + switch reason { + case "parent": + cancel() + case "stop": + close(stop) + want = errRunStopped + case "superseded": + close(superseded) + want = errSuperseded + } + calls := 0 + operation := func(context.Context) error { calls++; return nil } + delivery := testDelivery(operation, operation) + delivery.stop, delivery.superseded = stop, superseded + require.ErrorIs(t, delivery.run(ctx), want) + require.Zero(t, calls, "an abandoned delivery must reach neither readiness nor the host") + }) + } +} + +type blockedDeliveryFlow struct { + *fakeLauncherFlow + entered chan struct{} + once sync.Once +} + +func (f *blockedDeliveryFlow) GetAddressForEndpoint(ctx context.Context, _, _, _ string) (string, error) { + f.once.Do(func() { close(f.entered) }) + <-ctx.Done() + return "", ctx.Err() +} + +func TestNativeDeliveryCanCancelWhileAnotherDeliveryHoldsTheSerializer(t *testing.T) { + cluster, flow, unique := launcherWithFakeFlow(t, func(string) bool { return true }) + blocked := &blockedDeliveryFlow{fakeLauncherFlow: flow, entered: make(chan struct{})} + cluster.flow = blocked + cluster.running[unique] = true + first, cancelFirst := context.WithCancel(context.Background()) + firstDone := make(chan error, 1) + go func() { firstDone <- cluster.deliver(first, "model") }() + t.Cleanup(func() { cancelFirst(); <-firstDone }) + select { + case <-blocked.entered: + case err := <-firstDone: + firstDone <- err + t.Fatalf("first delivery did not reach its owner call: %v", err) + case <-time.After(time.Second): + t.Fatal("first delivery did not start") + } + + second, cancelSecond := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancelSecond() + secondDone := make(chan error, 1) + go func() { secondDone <- cluster.deliver(second, "model") }() + select { + case err := <-secondDone: + require.ErrorIs(t, err, context.DeadlineExceeded) + case <-time.After(time.Second): + // Release the first call so the broken baseline can finish too. + cancelFirst() + <-secondDone + t.Fatal("canceled delivery is still waiting for another delivery's lock") + } +} + +// The lifecycle bound must reach the active owner call, not only its backoff. +// release lets even the broken baseline finish without leaving a goroutine. +func TestNativeDeliveryCancelsInFlightOperations(t *testing.T) { + for _, phase := range []string{"readiness", "delivery"} { + for _, reason := range []string{"parent", "stop", "superseded", "deadline"} { + t.Run(phase+"/"+reason, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + stop, superseded := make(chan struct{}), make(chan struct{}) + entered, release := make(chan struct{}), make(chan struct{}) + var enterOnce sync.Once + blocked := func(ctx context.Context) error { + enterOnce.Do(func() { close(entered) }) + select { + case <-ctx.Done(): + return ctx.Err() + case <-release: + return errors.New("test released blocked owner call") + } + } + delivery := testDelivery(hostAlwaysReady, blocked) + if phase == "readiness" { + delivery.ready = blocked + delivery.deliver = func(context.Context) error { + t.Error("delivery started after readiness was canceled") + return nil + } + } + delivery.stop, delivery.superseded = stop, superseded + if reason == "deadline" { + delivery.schedule.Deadline = 50 * time.Millisecond + } + done := make(chan error, 1) + go func() { done <- delivery.run(ctx) }() + t.Cleanup(func() { + close(release) + cancel() + }) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("owner call did not start") + } + want := context.Canceled + switch reason { + case "parent": + cancel() + case "stop": + close(stop) + want = errRunStopped + case "superseded": + close(superseded) + want = errSuperseded + case "deadline": + want = context.DeadlineExceeded + } + select { + case err := <-done: + require.ErrorIs(t, err, want) + if reason == "deadline" { + require.ErrorContains(t, err, "configured bound 50ms") + if phase == "readiness" { + require.ErrorContains(t, err, "checking host readiness") + } else { + require.ErrorContains(t, err, "delivering the manifest") + } + } + case <-time.After(time.Second): + t.Error("active owner call outlived its delivery lifecycle") + } + }) + } + } +} + +// Capture the hook through the same registration used by attachLauncher. The +// probe waits for cancellation, while service teardown has not even begun. +type cancelableReadinessFlow struct { + *fakeLauncherFlow + entered chan struct{} + canceled chan struct{} + calls atomic.Int32 +} + +func (f *cancelableReadinessFlow) ServiceReadiness(ctx context.Context, service string) *orchestration.ReadinessFailure { + if f.calls.Add(1) == 1 { + close(f.entered) + } + <-ctx.Done() + select { + case f.canceled <- struct{}{}: + default: + } + return &orchestration.ReadinessFailure{Service: service, Predicate: orchestration.PredicateLifecycle, Reason: ctx.Err().Error()} +} + +func TestNativeDeliveryRunningHookRetainsRunCancellation(t *testing.T) { + cluster, flow, unique := launcherWithFakeFlow(t, func(string) bool { return true }) + cluster.stop = make(chan struct{}) + blocked := &cancelableReadinessFlow{fakeLauncherFlow: flow, entered: make(chan struct{}), canceled: make(chan struct{}, 4)} + cluster.flow = blocked + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(func() { cancel(); cluster.close() }) + var hook func(string, int) + cluster.registerRunningHook(ctx, func(registered func(string, int)) { hook = registered }) + require.NotNil(t, hook) + hook(unique, 0) + select { + case <-blocked.entered: + case <-time.After(time.Second): + t.Fatal("registered hook did not start readiness") + } + cancel() + select { + case <-blocked.canceled: + case <-time.After(time.Second): + t.Fatal("run cancellation did not reach delivery before service teardown") + } + // Shutdown has deliberately not signaled cluster.stop: parent cancellation + // must suffice, even while service teardown would still be running. + select { + case <-cluster.stop: + t.Fatal("test closed launcher before observing cancellation") + default: + } + require.Eventually(t, func() bool { + cluster.waitersMu.Lock() + defer cluster.waitersMu.Unlock() + return len(cluster.waiters) == 0 + }, time.Second, time.Millisecond) + before := blocked.calls.Load() + hook(unique, 0) + require.Never(t, func() bool { return blocked.calls.Load() != before }, 30*time.Millisecond, time.Millisecond, + "late Running must not start another readiness probe") + require.True(t, deliveryAbandoned(context.Canceled), "run cancellation is expected abandonment") +} diff --git a/cmd/run/native_delivery_rpc_cancellation_test.go b/cmd/run/native_delivery_rpc_cancellation_test.go new file mode 100644 index 000000000..8861d67a9 --- /dev/null +++ b/cmd/run/native_delivery_rpc_cancellation_test.go @@ -0,0 +1,112 @@ +package run + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "net" + "sync/atomic" + "testing" + "time" + + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" + "github.com/codefly-dev/cli/pkg/gitops" + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" +) + +// This transport peer deliberately never answers ApplyManifest. It qualifies +// cancellation through the generated client, not authority admission semantics. +type stalledCompositionHost struct { + compositionv1.UnimplementedCompositionServiceServer + entered chan struct{} + canceled chan error + calls atomic.Int32 +} + +func (s *stalledCompositionHost) ApplyManifest(ctx context.Context, _ *compositionv1.ApplyManifestRequest) (*compositionv1.ApplyManifestResponse, error) { + if s.calls.Add(1) == 1 { + close(s.entered) + } + <-ctx.Done() + s.canceled <- ctx.Err() + return nil, status.FromContextError(ctx.Err()).Err() +} + +func TestNativeDeliveryCancelsTheGeneratedCompositionRPC(t *testing.T) { + for _, reason := range []string{"stop", "superseded", "deadline"} { + t.Run(reason, func(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + server := grpc.NewServer() + host := &stalledCompositionHost{entered: make(chan struct{}), canceled: make(chan error, 2)} + compositionv1.RegisterCompositionServiceServer(server, host) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + _, private, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + signer, err := solutionhost.NewInMemorySigner("delivery-test", private) + require.NoError(t, err) + composition := gitops.NewCompositionDelivery(conn, signer, nil, nil, nil) + native := &gitops.NativeDeliverer{Signer: signer, StateDir: t.TempDir(), Prepare: composition.Prepare, + Token: func(string) (string, error) { return "test-carrier", nil }} + manifest := &solutionhost.ModuleManifest{ + Schema: solutionhost.SchemaModuleManifestV1, Binding: "test.local.module_a", Alias: "module_a", Kind: solutionhost.KindModule, + Host: solutionhost.HostTarget{Coordinate: "test/local/host", Domain: "test"}, + Release: solutionhost.Release{Publisher: "test", Name: "module_a", Version: "0.1.0"}, + Workloads: []solutionhost.Workload{{Service: "api", Container: "api"}}, + Authority: &solutionhost.Authority{Principal: "module_a"}, + } + delivery := testDelivery(hostAlwaysReady, func(ctx context.Context) error { + _, err := native.Deliver(ctx, manifest) + return err + }) + stop, superseded := make(chan struct{}), make(chan struct{}) + delivery.stop, delivery.superseded = stop, superseded + if reason == "deadline" { + delivery.schedule.Deadline = 200 * time.Millisecond + } + done := make(chan error, 1) + go func() { done <- delivery.run(context.Background()) }() + select { + case <-host.entered: + case err := <-done: + t.Fatalf("delivery stopped before reaching ApplyManifest: %v", err) + case <-time.After(time.Second): + t.Fatal("ApplyManifest was not called") + } + want := context.DeadlineExceeded + switch reason { + case "stop": + close(stop) + want = errRunStopped + case "superseded": + close(superseded) + want = errSuperseded + } + select { + case err := <-done: + require.ErrorIs(t, err, want) + if reason == "deadline" { + require.ErrorContains(t, err, "delivery completion was not confirmed") + require.NotContains(t, err.Error(), "did not accept") + } + case <-time.After(time.Second): + t.Fatal("generated composition RPC outlived its delivery lifecycle") + } + select { + case err := <-host.canceled: + require.Error(t, err) + case <-time.After(time.Second): + t.Fatal("host did not observe RPC cancellation") + } + require.EqualValues(t, 1, host.calls.Load(), "cancellation must not retry the RPC") + }) + } +} diff --git a/cmd/run/native_delivery_schedule.go b/cmd/run/native_delivery_schedule.go index b703a9365..0799f4bf2 100644 --- a/cmd/run/native_delivery_schedule.go +++ b/cmd/run/native_delivery_schedule.go @@ -59,7 +59,7 @@ var ( ) func deliveryAbandoned(err error) bool { - return errors.Is(err, errRunStopped) || errors.Is(err, errWorkloadGone) || errors.Is(err, errSuperseded) + return errors.Is(err, context.Canceled) || errors.Is(err, errRunStopped) || errors.Is(err, errWorkloadGone) || errors.Is(err, errSuperseded) } // transientDeliveryRefusal reports whether a failed delivery attempt says the @@ -111,18 +111,66 @@ type hostDelivery struct { // are logged at info, each retry at debug. A non-transient refusal is // returned at once; at the deadline, the reason the host is still not // serving and the last refusal it answered. -func (d hostDelivery) run(ctx context.Context) error { - deadline := time.Now().Add(d.schedule.Deadline) +func (d hostDelivery) run(parent context.Context) (result error) { + bounded, timeout := context.WithTimeout(parent, d.schedule.Deadline) + defer timeout() + ctx, cancel := context.WithCancelCause(bounded) + defer cancel(nil) + // Stop and supersession must reach a readiness probe or delivery RPC already + // in flight, not just the wait between attempts. The owned calls honor ctx. + go func() { + select { + case <-d.stop: + cancel(errRunStopped) + case <-d.superseded: + cancel(errSuperseded) + case <-ctx.Done(): + } + }() + cancellation := func() error { + // Check the lifecycle channels synchronously too: a preclosed channel + // must prevent the first call without waiting for the watcher to run. + select { + case <-d.stop: + cancel(errRunStopped) + case <-d.superseded: + cancel(errSuperseded) + default: + } + return context.Cause(ctx) + } + deadline, _ := ctx.Deadline() backoff := d.schedule.FirstBackoff var reason string var lastRefusal error + phase := "checking host readiness" + defer func() { + if errors.Is(result, context.DeadlineExceeded) { + result = fmt.Errorf("delivery completion was not confirmed before its deadline (configured bound %s; %s; last wait reason: %q): %w", d.schedule.Deadline, phase, reason, result) + if lastRefusal != nil { + result = fmt.Errorf("%w (its last refusal: %w)", result, lastRefusal) + } + } + }() for attempt := 1; ; attempt++ { + if err := cancellation(); err != nil { + return err + } + phase = "checking host readiness" err := d.ready(ctx) + if cause := cancellation(); cause != nil { + return cause + } if errors.Is(err, errWorkloadGone) { return err } if err == nil { - if err = d.deliver(ctx); err == nil { + phase = "delivering the manifest" + err = d.deliver(ctx) + if cause := cancellation(); cause != nil { + return cause + } + if err == nil { if attempt > 1 { d.log.Info("delivered once the host was serving", wool.Field("attempts", attempt)) } @@ -134,10 +182,7 @@ func (d hostDelivery) run(ctx context.Context) error { lastRefusal = err } if !time.Now().Add(backoff).Before(deadline) { - if lastRefusal != nil && lastRefusal != err { - return fmt.Errorf("the host did not accept the delivery within %s: %w (its last refusal: %w)", d.schedule.Deadline, err, lastRefusal) - } - return fmt.Errorf("the host did not accept the delivery within %s: %w", d.schedule.Deadline, err) + return errors.Join(context.DeadlineExceeded, err) } if err.Error() != reason { reason = err.Error() @@ -145,6 +190,8 @@ func (d hostDelivery) run(ctx context.Context) error { } d.log.Debug("retrying the delivery", wool.Field("attempt", attempt), wool.Field("in", backoff.String()), wool.ErrField(err)) select { + case <-ctx.Done(): + return cancellation() case <-d.stop: return errRunStopped case <-d.superseded: diff --git a/cmd/run/native_delivery_schedule_test.go b/cmd/run/native_delivery_schedule_test.go index 703948467..5c6ff6931 100644 --- a/cmd/run/native_delivery_schedule_test.go +++ b/cmd/run/native_delivery_schedule_test.go @@ -170,7 +170,7 @@ func TestANativeDeliveryAtTheDeadlineNamesTheReasonAndTheLastRefusal(t *testing. }, refusing) delivery.schedule.Deadline = 30 * time.Millisecond err := delivery.run(context.Background()) - require.ErrorContains(t, err, "did not accept the delivery within 30ms") + require.ErrorContains(t, err, "configured bound 30ms") require.ErrorContains(t, err, "saas/auth-gateway: lifecycle: runtime start has not completed") require.ErrorContains(t, err, "HTTP 503: JWT validation not configured") } diff --git a/cmd/run/paired_delivery_test.go b/cmd/run/paired_delivery_test.go new file mode 100644 index 000000000..bfbe449ab --- /dev/null +++ b/cmd/run/paired_delivery_test.go @@ -0,0 +1,91 @@ +package run + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "fmt" + "net" + "os" + "path/filepath" + "sync" + "testing" + + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" + "github.com/codefly-dev/cli/pkg/gitops" + "github.com/codefly-dev/cli/pkg/run/identity" + modulecontract "github.com/codefly-dev/core/contracts/module" + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type pairedLauncherFlow struct { + fakeLauncherFlow + address string +} + +func (f *pairedLauncherFlow) GetAddressForEndpoint(context.Context, string, string, string) (string, error) { + return f.address, nil +} + +// The launcher witness checks actual factory wiring and filesystem readers. +// Authority admission itself is exercised in its owning repository. +type launcherPolicyPeer struct { + compositionv1.UnimplementedCompositionServiceServer + mu sync.Mutex + generations []uint64 +} + +func (p *launcherPolicyPeer) ApplyManifest(_ context.Context, r *compositionv1.ApplyManifestRequest) (*compositionv1.ApplyManifestResponse, error) { + m, err := solutionhost.ManifestFromVerified(r.GetCarrier().GetDocument()) + if err != nil { + return nil, err + } + p.mu.Lock() + p.generations = append(p.generations, m.Generation) + p.mu.Unlock() + return &compositionv1.ApplyManifestResponse{Generation: m.Generation}, nil +} + +func TestLauncherReservesGenerationFromChangedSolutionRoles(t *testing.T) { + workspace, env := writeHostedNativeWorkspace(t) + moduleFile := filepath.Join(workspace.Dir(), "modules", "model", resources.ModuleConfigurationName) + require.NoError(t, os.WriteFile(moduleFile, []byte("kind: module\nname: model\nservices:\n - name: model\ninterface:\n endpoints:\n - service: model\n endpoint: rest\n implements: [codefly.dev/composition@1.0.0]\n"), 0600)) + workspace, err := resources.LoadWorkspaceFromDir(t.Context(), workspace.Dir()) + require.NoError(t, err) + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + peer := &launcherPolicyPeer{} + server := grpc.NewServer() + compositionv1.RegisterCompositionServiceServer(server, peer) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + _, key, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + signer, err := solutionhost.NewInMemorySigner("test", key) + require.NoError(t, err) + vouch, err := identity.Start(env.Host.TrustDomain, env.Host.Audience, t.TempDir()) + require.NoError(t, err) + cluster := &launcher{workspace: workspace, env: env, signer: signer, vouch: vouch, stateDir: t.TempDir(), hostModule: "model", hostService: "model", flow: &pairedLauncherFlow{address: listener.Addr().String()}} + cluster.declare(map[string]gitops.NativeIdentity{"model/model": {Module: "model", Service: "model"}}) + contractPath := filepath.Join(workspace.Dir(), "modules", "model", modulecontract.FileName) + for _, action := range []string{"read", "read", "write", "write"} { + contract := fmt.Sprintf("schema: codefly/module-contract/v1\nprincipal: model\nnamespaces: []\nqueues: []\nscope_ceilings:\n - resource_kind: model.item\n actions: [read, write]\nroles:\n - name: installer\n scopes:\n - resource_kind: model.item\n actions: [%s]\nbindings: []\ndestinations: []\n", action) + require.NoError(t, os.WriteFile(contractPath, []byte(contract), 0600)) + // The production retry path constructs a new deliverer each attempt. + d, gateway, err := cluster.newDeliverer(t.Context()) + require.NoError(t, err) + require.Empty(t, gateway) + require.NotNil(t, d.Prepare) + m := &solutionhost.ModuleManifest{Schema: solutionhost.SchemaModuleManifestV1, Binding: "probe", Alias: "model", Kind: solutionhost.KindSolution, + Host: solutionhost.HostTarget{Coordinate: "local", Domain: "local"}, Release: solutionhost.Release{Publisher: "test", Name: "model", Version: "0.1.0"}, + Workloads: []solutionhost.Workload{{Service: "model", Container: "app"}}, Authority: &solutionhost.Authority{Principal: "model", ResourceKinds: []string{"model.item"}}} + _, err = d.Deliver(t.Context(), m) + require.NoError(t, err) + } + peer.mu.Lock() + defer peer.mu.Unlock() + require.Equal(t, []uint64{1, 1, 2, 2}, peer.generations) +} diff --git a/cmd/run/service.go b/cmd/run/service.go index d8526869b..6e9631022 100644 --- a/cmd/run/service.go +++ b/cmd/run/service.go @@ -20,6 +20,7 @@ import ( "github.com/codefly-dev/cli/pkg/control" "github.com/codefly-dev/cli/pkg/engine" "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/cli/pkg/gitops" "github.com/codefly-dev/cli/pkg/orchestration" "github.com/codefly-dev/cli/pkg/processgroup" "github.com/codefly-dev/cli/pkg/solutionrun" @@ -218,13 +219,13 @@ func runServiceCommand(cmd *cobra.Command, args []string) (returnErr error) { // every exit path — including failures — so a partially-started flow // never orphans agents or containers. stopFresh := func() error { + stopLauncher() // Cancel delivery before waiting for service teardown. if run.flowManager != nil { run.flowManager.Release(serviceName, run.flow) } shutdownCtx, shutdownCancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) err := stopService(shutdownCtx, run.flow) shutdownCancel() - stopLauncher() return err } defer func() { returnErr = errors.Join(returnErr, stopFresh()) }() @@ -900,11 +901,20 @@ func newRunFlow(ctx context.Context, workspace *resources.Workspace, module *res w.Info("Running with remotes", wool.Field("remotes", remoteServices)) flow, err := orchestration.NewFlow(ctx, workspace, module, service, env, orchestration.RunMode, - orchestration.WithRunModuleClosure(runModuleSeeds(module, runCoRoots)...)) + orchestration.WithRunModuleClosure(runModuleSeeds(module, runCoRoots)...), + orchestration.WithCompositionCheck(gitops.PermissionPreflight(false))) if err != nil { return nil, w.Wrap(err) } + generated, err := provisionGeneratedSecrets(ctx, workspace, env) + if err != nil { + return nil, w.Wrap(err) + } + if len(generated) > 0 { + cli.Info("generated %s, as environment %s's service-secrets declare (values never printed)", strings.Join(generated, ", "), env.Name) + } + flow.WithOutputSink(cli.NewOutputSink()) flow.WithLoadOnly(loadOnly) flow.WithInitOnly(initOnly) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 667a3e651..78a2e5ebd 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1661,6 +1661,14 @@ deploy time: /configurations//[.secret].env. Configuration is Codefly's, so provisioning it is a Codefly operation — not a shell script in your repository. Values are written but never printed. +A workspace may add to an imported group with configuration-extensions in +workspace.codefly.yaml. Env keys must be disjoint unless append explicitly +names the key and its delimiter (for example edge: {append: {MODULES: ","}}). +YAML mappings combine recursively and sequences append; duplicate scalar keys +are refused by name. Both an own and an imported group must exist. Without +this declaration, an own group replaces the imported group whole. +See docs/commands.md#extending-imported-workspace-configuration. + Secrets are refused when git would track the destination file, are stored 0600 inside a 0700 directory, and are never overwritten without --force so re-running setup cannot rotate a credential another service already holds. @@ -2717,6 +2725,7 @@ Subcommands: - [`codefly generate client`](#codefly-generate-client) - [`codefly generate contracts`](#codefly-generate-contracts) +- [`codefly generate permissions`](#codefly-generate-permissions) - [`codefly generate proto`](#codefly-generate-proto) - [`codefly generate runnable-bindings`](#codefly-generate-runnable-bindings) - [`codefly generate runnables`](#codefly-generate-runnables) @@ -2767,14 +2776,21 @@ module package carries the contract. gRPC and connect endpoints get a serialized FileDescriptorSet (contract.binpb) plus a copy of the service's proto sources; REST endpoints get their OpenAPI -document (openapi.json). HTTP, TCP, and MCP endpoints have no machine-readable -contract and are skipped, as is a connect endpoint whose service has no proto. +document (openapi.json). An HTTP endpoint gets the OpenAPI document its service +carries (openapi/api.swagger.json), when it carries one: a solution backend +publishes its routes there, each with its x-codefly-authority-policy. TCP and +MCP endpoints have no machine-readable contract and are skipped, as are an HTTP +endpoint whose service carries no document and a connect endpoint whose +service has no proto. For restricted listeners, module contracts.codefly.yaml maps service names under surfaces to service-relative JSON procedure inventories. This is CLI publication configuration, separate from the agent's spec. Multiple exported protobuf endpoints require an inventory; a full descriptor alone does not establish what -each listener serves. See docs/commands.md#generate-contracts. +each listener serves. An inventory may include implemented interfaces from other +protobuf packages; every named procedure must exist in the descriptor. For such +multi-package endpoints, package is empty and services[].fullName identifies +each package. See docs/commands.md#generate-contracts. Run it before module-package build; the package carries the result. --check is the CI drift gate: it regenerates into a temporary directory and compares @@ -2791,6 +2807,46 @@ Flags: --check do not write; exit 1 if the on-disk catalog differs from what would be generated --format string output format: text|json (default "text") --output string output directory for generated contracts (default: /contracts/api) + --permissions also generate typed permission constants; with --check, verify their freshness +``` + +## `codefly generate permissions` + +Generate typed Go and TypeScript permissions from published contracts + +``` +Read the same digest-checked published method policies used by authority delivery. +Generate typed permission constants and a method requirement table in Go and +TypeScript. Run codefly generate contracts first after editing an API. + +By default each Go service gets its own table in its own Go module: +//pkg/gen/permissions, the tree the go-grpc layout +generates its bindings into (source-dir is the Go agent's setting, "code" +unless the service declares another). A service's table holds the methods its +published contracts serve plus the module contract's foreign methods, which +name no service. What an endpoint serves is what the contract catalog records +for it (the declared surface, or every service of its package); a method a +published descriptor declares that no endpoint serves, such as a protocol other +modules serve, is left out with a note and needs no policy. The TypeScript file +sits beside the Go file. A Python service +gets no table: nothing in it reads one, since the authority's hop in front of it +decides every call, so it is skipped with a note. Any other service that +publishes a contract but has no go.mod at its Go source root fails by name. +--output writes the whole module's table to one directory instead. +--check compares every generated file without writing. A served method with no +policy, contradictory policies and colliding identifiers fail by name. This validates vocabulary, +not a person's resource access, installation or consent. +``` + +``` +codefly generate permissions [module] +``` + +Flags: + +``` + --check fail on missing or stale output without writing + --output string write the whole module's table to this one directory (default: each Go service's /pkg/gen/permissions) ``` ## `codefly generate proto` @@ -2870,10 +2926,27 @@ here, by name, rather than at a call. An operation published directly on a Connect endpoint uses that endpoint. The address is resolved, never configured: it is the one a run or a render of -the same environment gives the owner. A service that installs derived +the same environment gives the owner. Locally that is the address of the run's +naming scope: the environment's declared naming-scope, or --naming-scope, which +like `codefly run` overrides it for this invocation (an explicit empty value +clears it). A live run of that scope answers with its concrete allocation; with +no run the address is the one the run will bind. A service that installs derived operations declares runnable-bindings as a workspace configuration dependency and receives them like any other group, so the installer names no owner module. +An environment's runnable-call-endpoint: // in +workspace.codefly.yaml sends every call prepared for it to that declared +endpoint instead of the owner's listener: the composition's hop in front of its +modules, for example. Declared there, the file and its --check depend on the +workspace alone. --call-endpoint overrides the declaration for one invocation, +and an explicitly empty value sends each call to its owner. It must be an HTTP-based +endpoint of a workspace module, and it is resolved like an owner's (same +environment, same naming scope). Only the address moves: the owner is still +loaded and held to its served surface and published contract, and the +operation, the Connect procedure or REST verb and path, the contract, its +digest and the policy stay the owner's. The endpoint forwards the call; the +owner must still serve it. + In workspace.codefly.yaml, environments[].runnable-scope-selections maps each binding key (MODULE__OPERATION) to Core ScopeSelection messages: slot, invoke, and lookup. Every required slot needs exact resource kinds, actions and ids; @@ -2903,6 +2976,8 @@ Examples: codefly generate runnable-bindings codefly generate runnable-bindings --env staging codefly generate runnable-bindings --env staging --check + codefly generate runnable-bindings --env local --naming-scope demo --call-endpoint authority/hop/http + codefly generate runnable-bindings --env local --call-endpoint "" # owners, despite a declared runnable-call-endpoint ``` ``` @@ -2912,8 +2987,10 @@ codefly generate runnable-bindings [flags] Flags: ``` - --check do not write; exit 1 if the on-disk file differs from what would be generated - --env string environment supplying resolved addresses and runnable-scope-selections (default "local") + --call-endpoint module/service/endpoint declared HTTP-based endpoint, as module/service/endpoint, every prepared call is sent to instead of the owner's listener, overriding the environment's runnable-call-endpoint (empty: each owner's listener; the owner's operation, route, contract and policy are kept) + --check do not write; exit 1 if the on-disk file differs from what would be generated + --env string environment supplying resolved addresses and runnable-scope-selections (default "local") + --naming-scope scope scope addresses resolve in, as codefly run --naming-scope reads it: omitted keeps the environment's, empty clears it ``` ## `codefly generate runnables` @@ -3256,10 +3333,17 @@ codefly login [flags] Read or follow logs from the current Codefly session ``` -Show codefly's detailed session logs (~/.codefly/logs/.log). +Show codefly's detailed session logs (under the Codefly home logs directory). + +By default prints the last 100 lines from the latest-started invocation, +pretty-printed. Each invocation owns a separate directory; late shutdown records +from an older process stay in its files. The failure hint names the exact file. +CODEFLY_HOME (or --plugin-path) controls the log root. Old daily logs remain +readable, and are the default fallback when no invocation logs exist. -By default prints the last 100 lines of today's log, pretty-printed. This is the -log the failure hint points at, so the usual flow after a command fails is: +Follow stays on the selected file, including across later runs; it does not +switch files on rotation. Use --file to select a known invocation segment. +The usual flow after a command fails is: codefly logs # what just happened codefly logs -f # follow live (e.g. in a second terminal during a run) @@ -3275,7 +3359,7 @@ codefly logs [flags] Flags: ``` - --file string Read a specific log file instead of today's + --file string Read a specific log file instead of the latest invocation segment -f, --follow Follow the log live --path Print only the log file path --raw Print raw JSON lines (for jq/grep) diff --git a/docs/commands.md b/docs/commands.md index 92805601a..40e6e4340 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -64,6 +64,11 @@ export OPENAI_API_KEY=... codefly explain build service ``` +The `go install` needs read access to `codefly-dev/authority` and +`GOPRIVATE=github.com/codefly-dev/authority`: the CLI's module requires that +private repository's libraries, so it is not supported from outside the +codefly-dev organization while the repository stays private. + The reference `codefly-help` provider is built and installed separately from the main CLI. It calls the OpenAI Responses API without an SDK, defaults to `gpt-5.6-luna`, and accepts `CLI_HELP_MODEL` and `CLI_HELP_API_URL` overrides. @@ -195,6 +200,16 @@ signed manifest is delivered to the host once it runs, and the module is offered for installation in the host's own registry. See [module-manifests.md](module-manifests.md#a-native-run-the-run-answers-as-the-cluster). +A component declaring `solution.frontend.codefly.yaml` has its remote built +before native delivery. Codefly reuses the deployed frontend builder to run +`npm ci && npm run build` from the remote's own npm project (or the module root), +with the pinned Node image. Local edits and nonignored new files are included; +the frontend's `dist` and `node_modules` are excluded. BuildKit caches by input +content, so an unchanged build is reused. Failure names the component and includes the build's +output; an old dist is never delivered instead. The complete generated bundle +is retained by digest in the existing artifact cache. See +[module manifests](module-manifests.md) for source and cache semantics. + ### `codefly run service [name...]` Run one or more services locally with their dependency graph. @@ -286,6 +301,38 @@ manifests, and build and deployment operations ignore them. In-process callers select the identical resolver through `control.RunRequest{Service: "mind/mind", Profile: "local"}`. +#### Generated secrets + +A run generates the secrets its environment declares, the way `codefly deploy +secrets` generates a cell's: under `service-secrets.generate`. An environment +run locally names no secret store, since its store is the workspace's own +secret configurations, so it declares the generators alone: + +```yaml +environments: + - name: local + service-secrets: + generate: + - scope: workspace + configuration: hop + keys: [PROOF] + - scope: workspace + configuration: tracing-read-cursors + keys: [CURSOR_KEY] +``` + +Before it loads configuration, the run sets every declared key the +configuration does not hold yet to a value minted by the same generator, into +the file `codefly config generate` writes (`configurations//.secret.env`, +0600, refused when git would track it), and says which keys it generated, +never their values. A held value is never rotated. A `service` generator names +the services it covers; a local run guesses none. An environment whose +`service-secrets` names a store keeps its secrets there, and a run writes +nothing for it; a render refuses a secret in an environment that names no +store, since it carries secrets only as references into one. So a playground +needs no make target or script that calls `codefly config generate` before it +runs: its environment says what is generated. + ### `codefly run solution` Boot a whole solution as a unit from its root. A solution root is a workspace @@ -855,6 +902,16 @@ environments: shop/api: {hosts: [identity.example.test, {name: api.github.com, port: 8443}]} ``` +**A service reads its own egress from the same declaration.** A service that +declares the `egress` workspace configuration group receives, on every run and +render for the environment, `egress/HOSTS`: its `egress:` hosts as +comma-separated `host:port`, sorted and with every port explicit +(`api.github.com:8443,identity.example.test:443` for `shop/api` above), or empty +when the environment declares none for it. It is generated, never authored — a +render refuses an `egress.env` — so a service that admits only its declared +hosts never keeps a second copy of the list the cell allows +([workspace configuration groups](orchestration.md#workspace-configuration-groups)). + **Where a service's telemetry goes.** An environment's `telemetry:` block names the OTLP intake its services export operational traces to: `otlp-endpoint` as `host:port` (TLS; `insecure: true` for plaintext) or as an `http://`/`https://` @@ -2485,6 +2542,52 @@ the environment's declared publication path. with same-named services. Qualified values override bare values per key. Unknown qualified entries and ambiguous bare entries fail workspace validation. +#### Extending imported workspace configuration + +By default, an own group replaces an imported workspace's group whole. +To add product-owned keys instead, declare the extension in the consuming +`workspace.codefly.yaml`: + +```yaml +configuration-extensions: + edge: + append: + MODULES: "," + fixture-grants: {} +``` + +Both workspaces keep their contributions in the usual +`configurations//edge.env` and `fixture-grants.yaml` files. Codefly +selects each workspace's profile through the existing profile resolver, then +assembles the group before endpoint resolution and run/render injection. +No generated endpoint address needs to be copied into either file. + +For `edge`, platform-core declares `MODULES=robin,tracing` and the module +upstream keys; platform-obin declares `MODULES=wiki`, `WIKI_REST` and `FRONTEND`. +Codefly produces `MODULES=robin,tracing,wiki` and retains every upstream key. +Other duplicate env keys are refused by name, even if their values agree; +matching retains Core's case-insensitive, dash/underscore-equivalent rules. +`append` keys use the exact spelling in the consuming file and require a +nonempty delimiter and plaintext values on both sides. Empty or duplicate list +items, secrets, templates and unresolved `${profile}` values cannot be appended. + +For `fixture-grants`, platform-core's `roles` list contains the module roles; +platform-obin's `roles` list contains only wiki roles, plus its `installations`. +YAML mappings combine recursively and sequences append in imported-then-own +order. A scalar collision or a type mismatch is refused with its complete key +path, such as `fixture-grants key $.settings.mode`. Lists are ordered additions; +the owner consuming the document validates domain identities such as role names. +Repeated mapping keys, aliases, merge keys, multiple YAML documents, and mixing +an env group with a YAML group are refused. Secret YAML remains secret if either +contribution is secret. Diagnostics never print configuration values. + +An extension requires both an own group and an unambiguous imported group. +Differing sibling imports still conflict, and extension works transitively. +Unfulfilled profile requirements remain required. Configuration inspection +reports both contributing files, including both sources of an appended env key +or an assembled YAML document. This applies to workspace imports; it does not +change module-default overlays or profile derivation. + #### `codefly environment show` Print the resolved `resources.Environment` as YAML (default) or `--json`. @@ -3162,6 +3265,14 @@ codefly generate runnable-bindings --env staging | `--template` | Generation template, relative to `--output` or absolute (default: `--proto`/`buf.gen.yaml`) | | `--local` | Select `--output`/`buf.gen.local.yaml`; plugins still run inside the pinned companion | +When a v2 template declares `inputs`, Buf uses those inputs and their `paths` +and `exclude_paths`; Codefly does not replace them with `--proto`. Without +template inputs, `--proto` supplies the input. Relative template inputs resolve +against the template's directory and must be visible within the companion's +mount. Explicit `--path` follows Buf's behavior: it overrides each input's +`paths`, rather than intersecting with that list. Omit it to retain the +template's selection. + buf resolves each `out` in the template against the template's directory, inside the companion. The companion therefore mounts the nearest common ancestor of `--proto`, `--output`, the template's directory and **every** `out` the template @@ -3347,6 +3458,16 @@ the package name alone reaches every client and message schema. The scaffold dev dependency and a `build` script (`tsc -p tsconfig.json`) that produces `dist/` — the only directory, with the README, that the package ships. +The Python package root is not a purely generated directory either. A run +replaces `/_gen/` whole and, at the root, only the files a protoc plugin's +generated-code notice says it wrote — the facade (whose docstring reads +`generated by protoc-gen-codefly-facade-python from ; do not edit`), +including a stale one an earlier `--module-name` left, or a file opening with +`# Code generated by protoc-gen-. DO NOT EDIT.`. Every other root file — +a hand-written reader beside the facade, a helper the package exports, +`__init__.py` — is the developer's and survives regeneration byte for byte. The +notice is read from the file's leading comments and docstring only. + Use `codefly sync library-dependencies` to link a generated library into a service locally, and `codefly publish library ` to distribute it. @@ -3359,15 +3480,28 @@ module's `interface` declares (`module.codefly.yaml`'s `interface:` block) into `contracts/api` and writes `contracts/api/catalog.codefly.json`. gRPC and connect endpoints get a serialized `FileDescriptorSet` (`contract.binpb`) plus a copy of the service's proto sources; REST endpoints get their OpenAPI document -(`openapi.json`). HTTP and TCP endpoints have no machine-readable contract and are +(`openapi.json`). An HTTP endpoint gets the OpenAPI document its service carries +at `openapi/api.swagger.json`, when it carries one, exported exactly as a REST +endpoint's is (no agent is loaded for it: an `http` endpoint reports no API +details). TCP and MCP endpoints have no machine-readable contract and are skipped. When the module has a `module.package.codefly.yaml`, its `services[*].api-contracts` are updated to match. +An HTTP endpoint's document is how a solution backend, which serves plain HTTP, +publishes its routes and what a call to each must hold: every operation's +`x-codefly-authority-policy` (an `authority.hop.v1.Policy` as JSON). The route +policy rows the authority applies, `" "`, are derived from +it exactly as from a REST module's contract, so the document describes the +routes as the backend serves them: no `basePath` when its paths already carry +the prefix the authority's edge forwards them on (`/v1//…`). A solution +built on solution-runtime-go renders it with `solution.InterfaceDocument`. + An exported endpoint that carries no machine-readable contract is skipped with a -notice, never a failure: `http` and `tcp` endpoints, a `connect` endpoint on a -service with no proto, and a `rest` endpoint with no OpenAPI document. An -interface may export such an endpoint so composed modules can reach it (a -gateway's REST surface, say); that export is reachability, not a contract. +notice, never a failure: `tcp` and `mcp` endpoints, an `http` endpoint whose +service carries no OpenAPI document, a `connect` endpoint on a service with no +proto, and a `rest` endpoint with no OpenAPI document. An interface may export +such an endpoint so composed modules can reach it (a gateway's REST surface, +say); that export is reachability, not a contract. For a restricted protobuf listener, publish a procedure inventory projected from its runtime registration/routing tables. Configure it in the module's @@ -3410,17 +3544,17 @@ supply the inventory, since copying a full descriptor to each cannot prove which listener serves a method. A single endpoint keeps the full-descriptor default; restricted single listeners must also supply their inventory. -A declared inventory also settles which proto **package** each endpoint's -contract covers, which matters for a service that generates client stubs from -another service's API. Those protos declare services in a foreign package and -must sit in the owned tree for codegen to reach them, yet the endpoint does not -serve them. Without an inventory such a tree is refused — `proto declares -services in multiple packages …; a contract endpoint must be single-package` — -for a contract that is in fact single-package. With one, candidate packages are -scoped to those the inventory names. A service that declares no inventory is -unaffected, and so is one whose inventory names no package the descriptor set -declares a service in: the single-package rule and its diagnostics stand -unchanged, rather than yielding an empty contract. +A declared inventory may serve several protobuf packages on one listener, +including implemented interfaces such as `host.authority.v1` alongside the +service's own API. Every service and procedure is validated against the complete +descriptor set, including imports. Unlisted imported/client services stay out +of the catalog. Single-package endpoints retain `package`; a multi-package +endpoint writes `package: ""`, with each `services[].fullName` carrying its own +package. Package manifests carry the same empty value. Contract evidence and +client selection use the explicit inventory. Policy delivery refuses a missing +catalog for a multi-package contract instead of silently selecting no methods. +Without an inventory, inference still requires one service-owned package; a +descriptor alone cannot prove which of several packages a listener serves. This input is a stopgap for the released Core/agent endpoint report. In Core v0.18.0, `GrpcAPI.rpcs` is loaded from the service's shared proto, without @@ -3522,8 +3656,16 @@ and the MCP `list_runnables` tool, with facility `service` and a `source` of #### generate runnable-bindings -`codefly generate runnable-bindings [--env ] [--check]` prepares every +`codefly generate runnable-bindings [--env ] [--naming-scope ] +[--call-endpoint //] [--check]` prepares every operation the workspace's modules derived (`contracts/runnables/index.json`). +Addresses resolve in the run's naming scope (`--naming-scope` as `codefly run` +reads it: omitted keeps the environment's, empty clears it). The environment's +`runnable-call-endpoint` sends every prepared call to a declared HTTP-based +endpoint — a composition's hop — while the operation, route, contract, digest +and policy stay the owner's; `--call-endpoint` overrides it for one invocation. +See [naming scope and a declared call +endpoint](runnable.md#naming-scope-and-a-declared-call-endpoint). The existing environment declaration in `workspace.codefly.yaml` supplies both endpoint facts and `runnable-scope-selections`: a map from output binding key to a list of Core `ScopeSelection` messages. See [the selection YAML and refusal @@ -4329,3 +4471,52 @@ untouched and require explicit recovery by container ID; startup never relabels In the same scope, stopped orphans and running ephemeral orphans remain eligible, while live owners, running stateful containers and session-ledger-owned containers are preserved. Startup removal does not request deletion of volumes. + +### Generate permissions + +`codefly generate permissions [module]` reads the same digest-checked published +protobuf/OpenAPI method policies as authority delivery and emits +`permissions_gen.go` and `permissions_gen.ts` into each Go service's own Go +module: `//pkg/gen/permissions`, where `source-dir` is the +Go agent's setting (`code` unless the service declares another; its first +segment is where `go.mod` lives) and `pkg/gen` is the tree the go-grpc layout +generates its bindings into. Each service's table holds the methods its own +published contracts serve, plus the module contract's `foreign_methods`, which +name no service and so appear in every Go service's table. What an endpoint +serves is what the contract catalog (`contracts/api/catalog.codefly.json`) +records for it: the module's declared surface (`contracts.codefly.yaml`, see +[Generate contracts](#generate-contracts)), or every service of the endpoint's +package when it declares none. A method a published descriptor carries that no +endpoint serves (a protocol the module publishes for other modules to serve, +say, which those modules declare through their own `foreign_methods`) is in no +table and needs no policy; it is named in a note. Authority delivery reads the +same served set, so such a method is never delivered as the module's. A served +method with no policy fails by name. The TypeScript file +sits beside the Go file: no generator places TypeScript per service (`generate +client` writes a library). A Python service (a `python-*` agent) gets no table: +nothing in it reads one, because the authority's hop in front of it decides +every call, so it is skipped with a note and the module's Go services still get +theirs. Any other service that publishes a contract but has no `go.mod` at its +Go source root fails by name. `--output` writes the whole +module's table to one directory instead. Go constants have type +`Permission`, with `Kind()` and `Action()` accessors; TypeScript constants carry +literal types. Both include the method requirement table and any-checked-caller +marker. Identifier collisions and invalid policies fail by name. + +After editing an RPC, run `codefly generate contracts --permissions` to update +both contracts and permissions. CI runs the same command with `--check`; it +regenerates contracts for comparison and checks permission files without changing +them. `generate permissions --check` checks only against the published contracts, +so it cannot by itself detect an unpublished proto edit. With no flags it checks +every default destination; with `--output` it checks that one directory. Missing +files count as drift. Standalone generation is also available for published dependency packages. + +Run/refresh permission preflight also detects changed source trees against each +existing published protobuf endpoint's recorded snapshot. It warns during local +adoption and refuses strict source-backed delivery, naming the command to +re-export. This comparison does not compile source, inspect external imports, +or prove generated output freshness; keep the CI regeneration check above. +Published package dependencies do not require local source trees. + +Permission generation is available through the CLI; no separate MCP generator +was added. MCP run/test use the same composition preflight as their CLI paths. diff --git a/docs/deployment-secret-safety.md b/docs/deployment-secret-safety.md index 62578697a..61419a78b 100644 --- a/docs/deployment-secret-safety.md +++ b/docs/deployment-secret-safety.md @@ -43,3 +43,6 @@ permits contradictory sources. These checks validate the render and store relationship. They do not verify live service authentication, cluster health, durable storage, or historical disclosure. + +Secret rotation resolves the generator’s format and size through the same +environment declaration as initial generation, including its defaults. diff --git a/docs/development.md b/docs/development.md index 55533b55b..e9735062b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -120,15 +120,27 @@ codefly self build `go install` produces an operator-managed development build. It does not carry the authenticated release metadata and `codefly self update` will not replace -it: +it. It needs read access to `codefly-dev/authority`, a private repository whose +libraries the CLI requires, so it is not supported from outside the codefly-dev +organization; releases are the install path there: ```bash -go install github.com/codefly-dev/cli/cmd/codefly@ +GOPRIVATE=github.com/codefly-dev/authority \ + go install github.com/codefly-dev/cli/cmd/codefly@ ``` For a self-updating direct installation, use a signed release archive as described in [CLI releases and updates](cli-updates.md). +CI downloads these private libraries with the existing organization `GH_PAT` +secret, also used by companion checkout and release publication. The token is +passed only to the module download/bootstrap step; the download helper scopes +Git authentication to `codefly-dev/authority` without persisting Git settings. +Build, lint and test steps consume the populated module cache. Missing access +fails the download; no gate is skipped. The CI reader-app action remains +available to callers that configure its app credentials, but CLI workflows do +not depend on unconfigured reader-app secrets. + ### 5. Run tests ```bash @@ -340,3 +352,42 @@ make check-coverage # Run tests with coverage report - **Context propagation** through all functions - **Signal handling** in all long-running commands - **Error wrapping** with `w.Wrapf(err, "context message")` + +## Detailed log ownership + +The CLI's existing JSON file logger allocates one exclusive invocation directory +beneath the resolved Codefly home `logs` directory. Nested command contexts reuse +that sink and register it with Core's agent log forwarding only once. Context +completion does not close it: preparation, delegated service execution and final +shutdown belong to the same invocation. The process owns its final descriptor +lifetime. Explicitly closing a standalone logger is terminal and idempotent. + +Date/size rotation exclusively creates segments inside the original invocation +directory. Log discovery selects the latest-started invocation, then its latest +segment; delayed writes or rotations in an older process cannot change that +selection. Legacy root-level daily files remain a fallback and explicit paths +remain readable. A follower keeps its opened descriptor across rotation and later +runs; it does not automatically switch to a different file. + +This isolates the detailed JSON files of separate CLI processes. It does not +change caller-owned stdout/stderr capture, daemon/service logs, or establish +per-flow isolation inside a long-lived control process whose agent forwarding is +still global. Tests cover separate subprocesses, late writes and forced rotation, +repeated contexts through Core's actual log fanout, legacy discovery and terminal +close behavior. + +## Composition withdrawal policy + +The composition adapter sends a terminal signed withdrawal without an authority +sidecar. It does not read the removed module's method policies, ceilings or +solution roles: withdrawal must work after those source files disappear, and +live declarations contradict the tombstone. Ordinary delivery still signs and +pairs those policies with its carrier. Transport regressions cover both module +and solution withdrawals through the generated composition client. Successful `ApplyManifest` responses must acknowledge the sent generation. +The adapter retains the acknowledged request's digest, release and removal flag +for that delivery attempt. Native withdrawal completion uses the existing +`Applied` callback and requires that exact identity; it does not contact a +private HTTP route or mint an observer credential for an interface host. Hosts +without the callback retain the legacy HTTP observer. This is synchronous +response evidence, not a remote-state query after a host restart. Live launcher +shutdown/restart remains unqualified by these transport tests. diff --git a/docs/module-manifests.md b/docs/module-manifests.md index 91a165e9a..1aa20eba9 100644 --- a/docs/module-manifests.md +++ b/docs/module-manifests.md @@ -102,11 +102,26 @@ collection and read back), and stamps that artifact as the payload. The same render hands every service declaring the `artifact-cache` workspace configuration `REPOSITORY`, that repository, which a host reads bundles from by the digest it admits. A local render and a local k3d cluster's publish and -project nothing. A native run delivers what runs: the bundle its frontend -build wrote (`services/fe-remote/dist`, with `mf-manifest.json` at its root), -archived in core's canonical form, retained in the workspace's artifact cache -under its digest, and named as the payload; a declared page without a built -bundle is refused by name. +project nothing. Before native delivery, Codefly builds every declared page +through this same frontend builder and the owner's `package.json` build script +(`npm ci && npm run build`); no manual bundle build is required. Native builds +snapshot the current repository files selected by Git, including uncommitted +edits and nonignored new files, so sibling package dependencies remain available. +Without a repository, the component directory is the build context. `.git`, +`.codefly`, `node_modules` and this frontend's `dist` are excluded from local +snapshots, even if tracked. Shipped sibling package artifacts remain inputs. The +source tree is never modified and an old local dist is never used as proof of a +successful build. + +Docker BuildKit's existing content cache reuses the build layer for unchanged +source, recipe and pinned builder image. Changed selected input (including a +lockfile or sibling package) invalidates it; clearing the builder cache also +requires a build. Each delivery exports the resulting bundle to a fresh temporary +directory, verifies a regular `mf-manifest.json`, archives it in Core's canonical +form and retains it in `.codefly/selection-artifacts` under its digest. The +manifest names those exact bytes. A build failure refuses delivery with the +component name, build command and final 40 lines of build output; it never +falls back to a previous bundle. Headless components perform no frontend build. ## Declaring the host @@ -274,6 +289,22 @@ that is not a prepared binding fails the render by key. A module that asks for nothing — no contract, no scoped consumption — gains nothing: the host grants it its envelope whole, and a request would change that. +**A solution's roles are part of its contract.** A unit a person uses declares +the roles a person may hold on an installation of it in the same +`module.contract.codefly.yaml`, as `roles` beside its `scope_ceilings`, under +core's rules (an `installer` among them; each scope within the unit's own kinds, +or a permission at another module one of its bindings spells out for `invoke` +or `lookup`) — see core's `docs/module-contract.md`. The core manifest names the +kinds alone; for a composition-interface host the delivery carries each own +kind with its actions (`AuthorityManifestV1.scope_ceilings`, read once from the +contract's `scope_ceilings`) and the roles as declared +(`AuthorityManifestV1.roles`). A solution asking for scopes on `api.consumes` +declares roles by moving that consumption into the contract's bindings, since a +module asks for authority from one declaration. The CLI defines no roles schema +of its own: a unit still shipping the retired `roles.codefly.yaml` +(`codefly/solution-roles/v1`) fails the render and the delivery, naming the file +and the contract its kinds and roles now belong in. + The contract declares the principal, the operation bindings the module redeems (each with the operations it needs and a scope ceiling per operation), the module's queues and namespaces, its own scope ceilings and the destinations it @@ -301,7 +332,8 @@ refuses far from the contract that caused it. The reader holds the convention the modules publish against — `audience` from a `*-audience` key (or a `*-prefix` one: a module's prefix is the audience a capability for it is addressed to), `resource_kind` from a `*-resource-kind` key, `binding_key` from -a `*-binding` key — in either key spelling core accepts, and refuses a slot +a `*-binding` key, `audiences` from a `*-audiences` key and `resource_kinds` +from a `*-resource-kinds` key — in either key spelling core accepts, and refuses a slot whose key is named otherwise. A binding's **scope ceiling** is written per operation in one of two spellings, @@ -324,6 +356,61 @@ naming the other module's permission namespace literally — a namespace is fixe by the module that contributes it, exactly as its proto package is, while a service the composition chooses stays a slot. +A binding is **mandatory** unless its contract marks it `optional: true`: every +composition fills a mandatory binding's slots or the render refuses. An optional +binding is for a capability the module degrades without, and core's resolution +decides it on the slots alone. A composition that fills **none** of them — each +key absent from its group, or supplied empty, as a module's declared +configuration supplies a key nobody set — renders the module without it: the +delivered manifest carries no such binding, so no credential can be issued +through it, its calls are never read (the audience need not be composed), and +the render logs it, `module : optional binding is omitted from the +delivered manifest:` followed by every unfilled slot. Filled, it renders exactly +as a mandatory binding. Filled **in part**, it is resolved whole and each +unfilled slot is refused by name, the refusal saying the binding is optional and +was filled in part, because a misspelt key must never silently drop a binding +(`TestAnOptionalBindingFilledInPartIsRefusedByName`). A secret-classified record +fills its slot, and is refused as a secret rather than omitted around. + +A binding may be addressed to a **list of audiences** the composition writes — +for a generic module that reaches whichever modules a composition wires to it, +such as a store of comments reaching the owner of each kind of thing commented +on. It declares `audiences: {from: /-audiences}` in place of +`audience`, the value comma-separated with no space (`robin,documents`), and, +for a bare-action ceiling, `resource_kinds: {from: /-resource-kinds}` +whose value is `=` pairs. The render delivers it **once per +listed audience**, in core's resolution: binding `:` +addressed to that audience, its `calls` read from that audience's own published +contracts (so two owners declaring one protocol's methods differently get two +ceilings), its bare actions qualified by that audience's kind. A call one listed +audience does not serve is refused naming the binding, the call and the +audience; the audience that serves it is not blamed. The render logs +`module : binding is delivered once per listed audience, as` followed by +the delivered ids, since they are not the ones the contract spells. The issuer +holds each delivered binding to its one audience, exactly as a single-audience +binding, so a module is issued a context toward one owner by naming +`:`. An optional list composes with the rule above: the list +absent or empty omits the binding, and filled with its kinds missing is filled in +part, refused by name (`audience_list_binding_test.go`). A binding with +`audience` renders exactly as before, under its own id. + +A binding may be addressed to **the module itself** — a module admitting work +into itself on someone's behalf, such as a scheduler issued the capability an +occurrence is admitted under. It writes `audience: {self: true}`, and core's +resolution delivers it with the contract's principal as its audience. The +render holds that principal to the module's name **before** anything resolves +(the same check that refuses a module claiming another's principal), so the +self binding is addressed to exactly the principal the manifest's `authority` +block names, read from no workspace configuration value: a configured value +carrying the module's own name would restate an identity the render already +resolves, and could drift from it. Core holds what such a binding may ask for — +no `resource_kind` slot, every scope spelled out within the module's own +`scope_ceilings`, `self` on an audience only — see core's +`docs/module-contract.md`. It composes with the rules above: an optional self +binding is omitted by its other slots alone (its `binding_key`, say), and an +audience-list binding beside it is delivered once per owner as before +(`self_audience_binding_test.go`). + ## What publish settles @@ -1001,7 +1088,11 @@ run, the CLI is: (the service), and nothing else: no build. It is written under `.codefly/run//identities/` and handed to the process as `CODEFLY__WORKLOAD_TOKEN_FILE` (a pod reads the mounted path), - reissued at 80 % of its lifetime as a kubelet rotates a projected token. + reissued when at most 120 seconds of its 600-second lifetime remain. The + run polls the actual wall clock every five seconds, including time spent + asleep, so expired projections are renewed on the first poll after wake. + A failed refresh logs the workload name and reason, leaves that projection + due for retry on the next poll, and does not prevent other renewals. Nothing is withheld: until the host has applied the module's manifest a mint answers "not established yet", which the workload retries. - **not the mesh** — every hop is plain HTTP on loopback, and only the host's @@ -1014,10 +1105,27 @@ run, the CLI is: `/platform/_delivery/manifest` with a native credential for account `delivery` in the module's namespace. When the set of running identity workloads changes, the manifest is delivered again; its generation is - settled from its content — unchanged content replays its generation, - changed content is the next one — and recorded under - `.codefly/run//delivery/generations.json`, so a - restart replays rather than rewrites. + settled from its content. For a composition-interface host, that includes + the captured method policies, scope ceilings and solution roles, including + whether a policy document is present. The same captured declaration is + signed and sent: a policy-only change advances the generation once. + The run reserves that content and generation under + `.codefly/run//delivery/generations.json` before sending, + using a cancellable shared file lock and Core's atomic writer. Neither signing + nor network I/O holds the lock. A lost response or refusal keeps the reservation: + after restart unchanged content replays it; changed content advances it. + Entering paired settlement from an old live Core-only record advances once, + because the old record cannot establish policy equality. Withdrawals keep their + Core-only identity and read no policy source, so an old terminal withdrawal + still replays its exact generation. A reserved withdrawal is terminal locally: + only its exact content may replay, including when its response was lost or signing + failed before sending; a later live attempt cannot displace that reservation. An old record learns terminality + when its exact withdrawal is replayed. Older records alone cannot reconstruct + host tombstones they never recorded. Legacy HTTP delivery retains Core-only + settlement and also reserves before sending. Deleting the record is not a + recovery procedure; it loses the evidence needed to avoid rewriting a generation. + These rules do not establish policy-file watch/reload behavior or power-loss + durability beyond Core's atomic writer. - **the administrator** — before delivering, it makes sure the host holds the composition's envelope: `{"revision": N, "principals": {"": {"grant": }}}`, each grant read from the `principals` of @@ -1049,11 +1157,31 @@ that is not a URL, a manifest the host received and withheld with a reason. The wait and every change of its reason are logged at info with the service, its module and the reason. A workload not delivered by the deadline is reported as an error naming it, why the host is still not serving, the last refusal it -answered, and the consequence: every gateway route to its module answers 404 -until it is delivered, which a restart of the workload attempts again. A -workload has one delivery in flight: a restart replaces the waiting one, and a +answered, and that delivery completion was not confirmed. Routes may remain +unavailable, but a lost response does not establish that the host refused or +failed to apply the manifest. A restart of the workload attempts delivery again. +A workload has one delivery in flight: a restart replaces the waiting one, and a workload the composition withdraws while it waits is not delivered. +The delivery deadline bounds active readiness and manifest calls as well as +backoff and waiting behind another delivery. Stopping the run or superseding a +workload's delivery cancels its active calls before service teardown waits; an +already-canceled delivery does not begin another attempt. Deadline errors retain the current phase, the last +wait reason and any last host refusal. Cancellation cannot undo a manifest the +host already applied, and a canceled call does not establish whether it applied. +Delivery still begins after Running; these bounds do not make authority available +to a service during its own startup. + +The native renderer also accepts an explicit admitted workload set independently +of the ready subset used for endpoint publication. Ready workloads must belong +to that admitted set. Its explicit frontend-deferral input permits authority-only +declarations before a bundle exists; ordinary frontend publication still requires +the complete built payload. Deferral omits the frontend in a full replacement +manifest, deliberately removing any previous current page. It does not preserve +an old page implicitly. A caller retaining a valid ready page must supply its +verified payload instead. The launcher does not yet use these inputs: graph +ordering, pre-start delivery and lifecycle integration remain separate work. + The host is pointed at the stand-in by `CODEFLY_NATIVE_IDENTITY_ANCHOR` and `SOLUTION_HOST_LOCAL_TRUST_ANCHOR`, both under `.codefly/run//host/`; it refuses both in a pod. The @@ -1099,3 +1227,83 @@ Named and not built: a **withdrawal path for a whole module** — a module removed from the composition has no render and so no publish, and its manifest stays applied on the host until a publish of that module's path with no instance writes its withdrawal. + +## Permission preflight + +Before native CLI/control-plane runs construct their dependency graph, a preflight +reads policies from the selected module closure, checks declaration validity and +resolves authority bindings through the existing contract resolver. Local runs +log findings as warnings during adoption; fixture roles are diagnosed again by +the development authority when their modules are delivered. Refreshing a running +composition rechecks declarations even if no new root was added. + +Render, direct deploy, development image deployment and source-module publication +reject invalid declarations before their build/apply effects. Hop warning mode +cannot bypass these checks. Unknown or contradictory method requirements are +invalid; an intentionally restricted business role is valid. Identical policies +published on multiple endpoints are accepted; conflicting policies for the same +method are refused rather than silently taking the first. + +**A method that declares no policy is refused only where policies are +consumed.** The structural checks above hold everywhere: both `requires` and +`any_checked_caller` on one method, a requirement with no action, a kind or +action outside the vocabulary, colliding identifiers. A published method that +declares nothing at all is refused, by name and with the reason, in two cases: + +- the environment's host (the service `host.delivery` names) declares that it + implements `codefly.dev/composition`, so every method's policy travels with + the manifest to the host's authority; or +- the module's `module.contract.codefly.yaml` sets + `require_method_policies: true`, which holds it to a policy on every method + wherever it is delivered. + +The host's own module — the module of the `host.delivery` service, `saas` for +`delivery: saas/accounts/connect` whatever `host.component` says (`saas` on a +local environment, `platform-obin` on a cell) — is not behind the hop: its +methods are the host's, never fronted by the policies the host receives, so the +first case does not hold it. Only that one module is set aside; every other module in the +composition is still refused by name, and the host module's own +`require_method_policies: true` still holds it. + +Toward any other host — the saas host every released composition delivers to — +an unmarked method passes, listed with neither field set exactly as +`MethodPolicies` reads it, and the hop in front of the module refuses it per +call. `codefly generate permissions` always requires a declaration: a table +names every method. + +*Migrating a module to the hop vocabulary.* Mark each RPC with +`option (authority.hop.v1.policy)` (`requires` or `any_checked_caller: true`) +and each OpenAPI operation with `x-codefly-authority-policy`, then run +`codefly generate contracts --permissions`. Until every method is marked the +module still renders toward a host that does not consume policies; before it is +composed under a host implementing `codefly.dev/composition`, or once it sets +`require_method_policies: true`, every unmarked method is refused by name. + +The checker validates published bytes and their package digests. For each +published protobuf endpoint resolved from local source (including service +overrides and materialized git clones), it also compares the service's `proto/` +tree with the snapshot recorded by `generate contracts`. Edits, added/deleted +files, and missing source or snapshots warn during local adoption and refuse +strict delivery. Module/service render checks the selected dependency closure +before effects, including producers reached through declared configuration groups +using the invocation-aware configuration reader; whole-composition render checks all modules before its first +build. Development image deployment validates the service selected by `--path` +even when replacing a packaged service. The existing ban on invocation configuration +overrides in delivery still applies. The diagnostic names the module, service, endpoint and changed +files, and directs the author to `codefly generate contracts `. +Even comment-only changes require re-export before strict delivery. + +Verified materialized packages retain artifact checks without requiring source; +the materializer's receipts distinguish them from checkouts. Unresolved or stale +materialization is reported, not silently treated as an artifact exemption. + +This early guard covers existing published protobuf exports, not a newly added +endpoint with no export, OpenAPI source changes, external imports, descriptor +derivation or generated clients/permission constants. Modules with no package +manifest have no recorded source snapshot to compare. Run +`codefly generate contracts --permissions --check` in CI for full contract +regeneration and permission-output freshness. Static checks cannot prove resource visibility, active +installation, consent, provider availability or a successful runtime call. +Packaged solution publications without a source module and rollback of old +rendered artifacts still need artifact-level permission qualification; source +preflight alone does not validate those paths. diff --git a/docs/orchestration.md b/docs/orchestration.md index 876c65779..87f39ee03 100644 --- a/docs/orchestration.md +++ b/docs/orchestration.md @@ -159,6 +159,19 @@ elsewhere: "supplied only by a local k3d or cell render; absent here". See for its values and environment requirements. No group carries a certificate: a workload's transport identity is the mesh's, never a file it reads. +The `egress` group is generated too, from the environment's `egress:` block: +a service that declares it in `workspace-configuration-dependencies` receives +`HOSTS`, its own declared external hosts as comma-separated `host:port` +(sorted, every port explicit, 443 where the declaration names none) — the same +list the cell file carries for it, so the cell's allowance and the service's own +check never disagree — and `HOSTS` empty when the environment declares no egress +for it. Every path delivers it the same way, from the shared resolution: a +native or container `codefly run` and a render alike (the process reads +`CODEFLY__WORKSPACE_CONFIGURATION__EGRESS__HOSTS`). Like `module-authority` it +is never a manager dependency, a render refuses an authored file for it, a run +profile can exclude it, and `doctor workspace` reports it as generated on every +environment. + An optional group — `runnable-bindings`, which `codefly generate runnable-bindings` writes nothing for when the environment prepares no binding (`environments.OptionalWorkspaceConfiguration`) — that the workspace does not @@ -384,6 +397,20 @@ whose graph is a slice of the workspace, and judging it against that slice would report a real producer as "not a service of this workspace" and refuse the run. Whether a producer is in *this* run is not a plan-time question. +A group a consumer **declares** is judged against the plan's graph and, beyond +it, every other service of the workspace except one this run excludes. A +module-closure run's graph holds the seeds' modules and the modules their +service dependencies reach — never a module only a configuration value names — +so `codefly run service authority/hop` alone does not contain the producers its +own group references. Such a producer is not a plan-time fault: the resolution +drops the value at WARN, naming the consumer, the value, which reference of it +and the producer, exactly as for a root group. Only membership is relaxed: the +endpoint must still exist and be visible to the consumer, a producer the +workspace does not have is still "not a service of this workspace", and a +producer the run **excludes** (`--exclude-dependency`, a run profile) still +refuses the run naming the exclusion — the exclusion and the group that +references it are coupled, and a value is never dropped because of one. + ##### What this refuses that used to be tolerated This is a **behaviour change for existing compositions**, and it can refuse a @@ -836,13 +863,32 @@ RuntimeInit → RuntimeStart (once init completes) RuntimeStart → done (service is running) ``` -At each transition, the policy checks the dependency graph: if service B depends on service A, then B's `RuntimeLoad` won't fire until A's `RuntimeLoad` completes. Services at the same DAG level transition in parallel. +At each transition, the policy checks the dependency graph: if service B depends on service A, then B's `RuntimeLoad` won't fire until A's `RuntimeLoad` completes. The playbook executes queued actions synchronously; a callback cannot wait for another action later in that queue to start its prerequisite. Required ordering belongs in the dependency graph. If an action fails (executor returns `Wait`), the action is marked as `Failing` and the PauseManager handles retry. +`World.BeforeRuntimeStart`, when supplied by the run's composition owner, runs +synchronously after request validation and immediately before each local agent +`Runtime.Start` RPC, including restarts. Refusal or cancellation prevents that RPC. +Remote services and actions that only initialize or validate never invoke it. +The callback is an admission boundary; it neither schedules prerequisites nor +establishes host readiness by itself. Native manifest delivery is not yet wired +to this callback and still follows the Running hook. + ### RuntimeTestPolicy -Same as RuntimeStartPolicy, but adds `RuntimeTest` after `RuntimeStart` for the target service. The playbook stops after the test action completes. +The agent's test dependency mode selects what starts before the target's test: + +- `NONE` initializes only the target and starts no process. +- `START_DEPENDENCIES` starts the dependencies, then passes a sequencing barrier + for the target without starting it. +- `START_STACK` starts dependencies and target. + +Successful real starts emit Running through the same lifecycle hook as an +ordinary run, making them visible to readiness and delivery. The target barrier +emits no Running event and calls no pre-start admission hook. Failed, waiting, +or canceled starts do not become Running. The playbook stops after the target's +test action completes. ### BuilderBuildPolicy (BuildPolicy) diff --git a/docs/runnable.md b/docs/runnable.md index 7191c99f5..00e872646 100644 --- a/docs/runnable.md +++ b/docs/runnable.md @@ -20,7 +20,7 @@ not; nothing here is a promise about the unimplemented parts. | `codefly add runnable --agent=: --handler=` | Create a declaration and scaffold through the real Builder agent | | `codefly build runnable [--output=] [--json]` | Prepare and package through the agent, verify archive bytes, write `artifacts/runnable-package.json` | | `codefly generate runnables [module] [--check]` | Derive a SERVICE-facility package for every gRPC method a module's contracts mark with the operation option | -| `codefly generate runnable-bindings [--env=] [--check]` | Resolve the environment's scope selections and owner endpoints into prepared bindings | +| `codefly generate runnable-bindings [--env=] [--naming-scope=] [--call-endpoint=//] [--check]` | Resolve the environment's scope selections, owner endpoints and declared `runnable-call-endpoint` into prepared bindings | | `codefly list runnables [--module=] [--json]` | List the workspace's runnables with their immutable identity, pinned agent, execution facilities and timeout | | `codefly show runnable [--version=] [--json]` | Show one runnable's contract, execution bounds and dependency resolution | | `codefly agent install : --kind=runnable` | Download a released runnable language agent into the local Codefly cache | @@ -147,7 +147,8 @@ published served set contains both the operation and its Lookup; no match or multiple matches are refused. A directly published Connect operation uses that endpoint with the same membership checks. Legacy gRPC-only catalogs must publish the called Connect surface before binding. Endpoint addresses still come from -the existing network resolver. +the existing network resolver, in the run's naming scope; see [naming scope and +a declared call endpoint](#naming-scope-and-a-declared-call-endpoint). An environment that prepares no binding has no file when no module derives an operation. That absence is an answer, not a missing file: a service that @@ -227,6 +228,54 @@ There is no additional selection file, CLI policy vocabulary or CLI scope resolver. Tests verify the prepared binding and the identical policy as a Core resolved-policy receipt; this command emits the binding configuration only. +### Naming scope and a declared call endpoint + +A local binding is read by a run, so its address is the one that run binds. +`--naming-scope` follows `codefly run`: omitted, the environment's declared +`naming-scope` applies; passed, it replaces it for this invocation; passed +empty, it clears it. A live run of that scope answers with its concrete +allocation, and with no run the address is the one a run of the scope will +bind (the resolver `codefly endpoint` uses). Generate with the scope you run +with: + +```sh +codefly generate runnable-bindings --env local --naming-scope demo +codefly run service ... --env local --naming-scope demo +``` + +An environment declares the endpoint every call prepared for it is sent to +instead of the owner's listener — the composition's hop in front of its +modules, for example: + +```yaml +environments: + - name: local + runnable-call-endpoint: authority/hop/http +``` + +Declared there, a plain `codefly generate runnable-bindings` and its `--check` +prepare through it, so the committed file depends on the workspace and not on a +flag retyped per invocation. `--call-endpoint //` +overrides the declaration for one invocation (`--call-endpoint ""` sends each +call to its owner), and an environment that declares nothing keeps working with +the flag alone; `--check` names the flag in its regeneration command only when +the file was prepared for an endpoint other than the declared one. The +declaration is installation input: it is never sent to an agent. The coordinate +is parsed as an endpoint +reference, must name a declared endpoint of a workspace module, and must be +HTTP-based (`http`, `connect`, `rest` or `mcp`), because a prepared call carries an +HTTP base URL; it resolves in the same environment and scope as the owners. +Nothing infers such an endpoint: the composition that runs the hop names it. + +Only the address moves. Each owner is still loaded and held to its served +Connect surface (operation and Lookup) and its published contract before the +address is replaced, so a hop never makes an unserved or stale operation look +callable. The operation, the Connect procedure or REST verb and path, the +contract, its digest and the policy stay the owner's. One generation names at +most one call endpoint, and the output file is the profile's: two generations +for different scopes into one profile overwrite each other, so parallel runs +use separate workspaces or profiles. + ### Identity and coexistence A runnable is identified by workspace/module/name **@ version**. A version is diff --git a/go.mod b/go.mod index c2ccd745d..6bd56e92f 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,10 @@ require ( github.com/asottile/dockerfile v3.1.0+incompatible github.com/blang/semver v3.5.1+incompatible github.com/briandowns/spinner v1.23.2 - github.com/codefly-dev/core v0.19.7-0.20261023201433-257e6b234bd3 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261024211423-39f45e9d69af + github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025010902-dc41de4126ae + github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261023192826-bc4d12956a06 + github.com/codefly-dev/core v0.19.7-0.20261025175252-88f4434c68c7 github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.7 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 @@ -43,6 +46,7 @@ require ( golang.org/x/sync v0.23.0 golang.org/x/sys v0.49.0 golang.org/x/term v0.46.0 + google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b // HELD at v1.83.2: v1.84.0 is affected by GO-2026-6443 (server panic via a // missing :authority/Host header) and the advisory's only fix for the 1.84 // line is an unreleased v1.85.0-dev pseudo-version, so there is nothing to @@ -237,8 +241,7 @@ require ( golang.org/x/net v0.60.0 // indirect golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect diff --git a/go.sum b/go.sum index 4c679558b..f3ea48bc0 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,16 @@ +al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= +buf.build/go/hyperpb v0.1.3/go.mod h1:IHXAM5qnS0/Fsnd7/HGDghFNvUET646WoHmq1FDZXIE= buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM= buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs= +buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q= cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE= @@ -14,12 +19,22 @@ connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs= connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= connectrpc.com/cors v0.1.0 h1:f3gTXJyDZPrDIZCQ567jxfD9PAIpopHiRDnJRt3QuOQ= connectrpc.com/cors v0.1.0/go.mod h1:v8SJZCPfHtGH1zsm+Ttajpozd4cYIUryl4dFB6QEpfg= +cyphar.com/go-pathrs v0.2.5/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.5.0/go.mod h1:PXe2h+LKcWTX9afWdZoHyODqR4fBa5boUM/8uJfZ0Jo= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -31,26 +46,55 @@ github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSC github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= +github.com/Microsoft/hcsshim v0.15.0-rc.1/go.mod h1:HWvvUPIy9HF6LotILj1G4VyS065rcLQ6tqj6tMUdOfI= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794/go.mod h1:7e+I0LQFUI9AXWxOfsQROs9xPhoJtbsyWcjJqDd4KPY= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/anchore/go-struct-converter v0.1.0/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anthropics/anthropic-sdk-go v1.63.1 h1:M9dIoZzUWB453ulVpBkQp3FX0769udzrTscDrsJk1w4= github.com/anthropics/anthropic-sdk-go v1.63.1/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asottile/dockerfile v3.1.0+incompatible h1:DovtIJuWXp2xbTxT4OkF753kfAkwizIgmewMrq/T/ok= github.com/asottile/dockerfile v3.1.0+incompatible/go.mod h1:z3uYnlNGA9635hb4kbb2DRnlR9XLQ4HsYsDer3slsjA= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= +github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.5/go.mod h1:M/qt7xBBXqilBwNmrO1yiu0cywZwQx5aqtKpdJN9J2A= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24/go.mod h1:ls5ytnwLTcQaUu32fMYXFI3MjpKuTwL840PAm9iqyEg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32/go.mod h1:9JS1UpfVvyD/ZPX8GsKb/Pq8scEM+7GP5fqh9SwH7po= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0/go.mod h1:fcvq5L7dK+5cQFicEJwpI6e6Wn8NY2i6yT5wRLYVc7s= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg= +github.com/aws/smithy-go v1.27.4/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= @@ -61,6 +105,8 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.24.6/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -69,8 +115,10 @@ github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2 github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/buger/jsonparser v1.6.1 h1:I0phFv0PlbLHnM7TZAVjZ2MJ2/eWRTDyuO7GLR98IEs= github.com/buger/jsonparser v1.6.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bwesterb/go-ristretto v1.2.4/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -83,6 +131,7 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM= @@ -99,12 +148,20 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= -github.com/codefly-dev/core v0.19.7-0.20261023201433-257e6b234bd3 h1:Qi+DwoxKK7UU6OVmiwj+bLIqlvrLXk5ttnz96+2f97A= -github.com/codefly-dev/core v0.19.7-0.20261023201433-257e6b234bd3/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261024211423-39f45e9d69af h1:2PWZxCPQLn/dQmcNST993g11nfWNgNc9YOvWIhr5pn0= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261024211423-39f45e9d69af/go.mod h1:wxXASIpNKX7bzQzd0ZB17Xj2CFlO1ALRHqDaEoER+is= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025010902-dc41de4126ae h1:GURYozeJoFfzwsW0iyjdyQ7w7+qpa/6vwWw4SxTqOxg= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025010902-dc41de4126ae/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= +github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261023192826-bc4d12956a06 h1:Gh531vlcnmPL41VurXGoVyn5Oyf5uJWH9j+wuvoPKAg= +github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261023192826-bc4d12956a06/go.mod h1:wPunnn+I0MM36rLqhRiCbIB/bqIypVA9+YjfQRHmgqY= +github.com/codefly-dev/core v0.19.7-0.20261025175252-88f4434c68c7 h1:sFkQTBSvAjzEBYJYHVFfzXdudFbHXIj6ccDLb8AlZNs= +github.com/codefly-dev/core v0.19.7-0.20261025175252-88f4434c68c7/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= @@ -115,21 +172,42 @@ github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 h github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458/go.mod h1:hwtrdGtgh8i2xzjgihyd4wY2u+UK7nHQz30OGKL0n+M= github.com/compose-spec/compose-go v1.20.2 h1:u/yfZHn4EaHGdidrZycWpxXgFffjYULlTbRfJ51ykjQ= github.com/compose-spec/compose-go v1.20.2/go.mod h1:+MdqXV4RA7wdFsahh/Kb8U0pAJqkg7mr4PM9tFKU8RM= +github.com/containerd/accelerated-container-image v1.3.0/go.mod h1:EvKVWor6ZQNUyYp0MZm5hw4k21ropuz7EegM+m/Jb/Q= +github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= +github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/containerd/containerd/api v1.11.1/go.mod h1:CaQFRu+N1MtbgL6JDOJLUB1hCKESU1lD6MuTJhgtdlw= +github.com/containerd/containerd/v2 v2.3.3/go.mod h1:rHKGm3VW6wNrINb3x8mNT+w7qYXFVElTt/8HTuxVhD4= +github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= +github.com/containerd/fuse-overlayfs-snapshotter/v2 v2.1.7/go.mod h1:gzkTvoDZmX6hLuNn6qQ8s3wkTJ+Va9SbT76v07GzVK0= +github.com/containerd/go-cni v1.1.13/go.mod h1:nTieub0XDRmvCZ9VI/SBG6PyqT95N4FIhxsauF1vSBI= +github.com/containerd/go-runc v1.1.0/go.mod h1:xJv2hFF7GvHtTJd9JqTS2UVxMkULUYw4JN5XAUZqH5U= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/nydus-snapshotter v0.15.15/go.mod h1:L96yO+4iE6qqDiqXKhxMXBoPeaE7JgzXir9yanUVuOY= +github.com/containerd/platforms v1.0.0-rc.4/go.mod h1:lKlMXyLybmBedS/JJm11uDofzI8L2v0J2ZbYvNsbq1A= +github.com/containerd/plugin v1.1.0/go.mod h1:qBTum+A8lJ6lO44A19Eo7y1OlcLj4OWFH1DA/vnHmcc= +github.com/containerd/stargz-snapshotter v0.18.2/go.mod h1:iS0a4lgCFjGbdBJNrm1jwvaMFGGnQ6PZ5Sd09i060h8= +github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= +github.com/containerd/ttrpc v1.2.9/go.mod h1:jjtQRwXm4DL3KsHKW8vDiUOV6wO0hi6IPhmJhxU7aEs= github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ= github.com/containerd/typeurl/v2 v2.3.0/go.mod h1:Qk+PAdUYArVj41TnGi6rJ+48RF0PkcTc4i/taoBcK0w= +github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4= +github.com/containernetworking/plugins v1.9.1/go.mod h1:fj7kS55qg3o/RgS+WGsF3+ZxwIImMPusQZKzBpcSr4c= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creativeprojects/go-selfupdate v1.6.0 h1:Bu3cIgdyfI1Pg8XsL8nbaT2uMjfZ8HIoxnBmPJbN0sw= github.com/creativeprojects/go-selfupdate v1.6.0/go.mod h1:Ids8O474XGQG0jZ5vpBIhWffcGYjUP6ccOI0mMcvQbI= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -138,6 +216,9 @@ github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454Wv github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/dekobon/tree-sitter-groovy v0.3.0 h1:bLi4b7WAQr6Q7nGw529JTHr+jmqvo4yK6nkmGbYQRFU= github.com/dekobon/tree-sitter-groovy v0.3.0/go.mod h1:5vaBT8Zqmry78Kt/5kluJcvLwRnVR8xtsbGe+K5SAJA= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= @@ -146,20 +227,30 @@ github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4sav github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc= github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -180,6 +271,7 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.19.3 h1:qHttbZ+Am7wEjdTpR1XMQ4rl42FK9SIXzZ4o9x7s6M4= github.com/go-git/go-git/v5 v5.19.3/go.mod h1:Ye4C8dVigkqrv9UsB4NMdSLV4yAIkLiIhW61gcOyhl4= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -198,6 +290,8 @@ github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259 github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs= github.com/go-openapi/loads v0.25.3 h1:V+jKy/thXWdLJUuYC8sZX2dICyAa8M3DokF70jj34P0= github.com/go-openapi/loads v0.25.3/go.mod h1:LgLyCSOLBL2Qnj0Ps1oo2YH8jZoEKsMeVZCc+ALIxFo= +github.com/go-openapi/runtime v0.33.2/go.mod h1:NQpSLiIsEAIpZDEs6xUrhjm6ZiQh85X8bHKlqLPgNP8= +github.com/go-openapi/runtime/server-middleware v0.33.2/go.mod h1:E3mWY61/UgBJ5EUmKh+h6A1oQQdD1U4raowVMmPmQug= github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk= github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8= github.com/go-openapi/strfmt v0.27.2 h1:SG32SlbwNy92s0KJiVxt2joJeFdqIYHvwrA0OU6HqzQ= @@ -210,6 +304,7 @@ github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q= github.com/go-openapi/swag/fileutils v0.29.2 h1:mdUL+Vw5ah1fO1AvFCoHeIyv7YZsCz2MN3KNqd/lLVI= github.com/go-openapi/swag/fileutils v0.29.2/go.mod h1:7QKmmodjAebaq61lGVJa8ldLdGeMF4DSgYv8tXOGGo0= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k= github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs= github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo= @@ -232,33 +327,50 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8 github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs= github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho= github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak= +github.com/go-openapi/validate v1.0.0/go.mod h1:wwXGRqMQzOZ7PCqBcgNk+DD9+Cacnxv7we5T0M/eA3Y= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= github.com/google/go-github/v86 v86.0.0 h1:S/6aANJhwRm8EQmGKVML3j41yq0h2BsTP8FnDkO7kcA= github.com/google/go-github/v86 v86.0.0/go.mod h1:zKv1l4SwDXNFMGByi2FWkq71KwSXqj/eQRZuqtmcot8= github.com/google/go-github/v89 v89.0.0 h1:35bEK5XoEcF3PZrlVbl9XN63f5BcJRA/UGkxeC9xPg0= github.com/google/go-github/v89 v89.0.0/go.mod h1:QLcbU0ipeAqQuR5KSg8c2lql4Qk1EwJ2dWz/0rP4Nho= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls= github.com/graph-gophers/graphql-go v1.9.0/go.mod h1:23olKZ7duEvHlF/2ELEoSZaY1aNPfShjP782SOoNTyM= github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 h1:Bd7KaOxzULLxtZ/K5s1aLbWhR0+5RToO65TXHsf3bqQ= github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0/go.mod h1:nN7ts3dFXKtCZWc//yfkpcQNKJABg16/uDVAZpLDalo= +github.com/hanwen/go-fuse/v2 v2.9.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -266,26 +378,33 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hiddeco/sshsig v0.2.0/go.mod h1:nJc98aGgiH6Yql2doqH4CTBVHexQA40Q+hMMLHP4EqE= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= +github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -293,10 +412,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0= github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= @@ -322,14 +443,26 @@ github.com/moby/buildkit v0.32.2 h1:Sfy7+u6dUv/2yuBc9KCoK70Re8atuV8aPZ5UOC068Vc= github.com/moby/buildkit v0.32.2/go.mod h1:0GB/EJ1d+4VIVqIAgy3asaoGkVXy7IrDfVy7mPhOvg8= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/policy-helpers v0.0.0-20260722051018-856be88baec4/go.mod h1:77BSYHqLljY8i6FWZhQ2V7xmgNP3eftoIRV5yHYspDs= +github.com/moby/profiles/seccomp v0.2.3/go.mod h1:8m3qkkWZXrRsMqlUUN2zyccnYmBf3EAdQYPMVJ3NBhk= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/capability v0.4.0/go.mod h1:4g9IK291rVkms3LKCDOoYlnV8xKwoDTpIrNEE35Wq0I= +github.com/moby/sys/mount v0.3.5/go.mod h1:WUQDO+/uCiCIkIztx8SrwIDVn2dtMFRBebRhpDFT71M= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/moby/sys/reexec v0.1.0/go.mod h1:EqjBg8F3X7iZe5pU6nRZnYCMUTXoxsjiIfHup5wYIN8= github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8= +github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= @@ -342,6 +475,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= @@ -352,13 +487,21 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116/go.mod h1:DKDEfzxvRkoQ6n9TGhxQgg2IM1lY4aM0eaQP4e3oElw= +github.com/opencontainers/selinux v1.15.1/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ= +github.com/package-url/packageurl-go v0.1.1/go.mod h1:uQd4a7Rh3ZsVg5j0lNyAfyxIeGde9yrlhjF78GzeW0c= github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pjbgf/sha1cd v0.7.0 h1:ZRNPKHj+gfkLBf0KJv/p1Hmz+7bqCT5o311YX0nq+DA= github.com/pjbgf/sha1cd v0.7.0/go.mod h1:pKR5Li+qTCo+ebqITZfwPlJGoCFCvSO00qxjbNtTUFQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -366,23 +509,38 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU= github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= github.com/scagogogo/python-requirements-parser v0.0.0-20250717025652-6ca77234c827 h1:+Fu0RVDu7WxwtkELZ9V0PjG+HdxL+ANkGKa6gp45J/g= github.com/scagogogo/python-requirements-parser v0.0.0-20250717025652-6ca77234c827/go.mod h1:3rCK5dhjXuiTnMXVkO2n9eWZ21qWreUtptCmCT9+rNg= +github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b/go.mod h1:/yeG0My1xr/u+HZrFQ1tOQQQQrOawfyMUH13ai5brBc= +github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shoenig/go-m1cpu v0.2.2 h1:4nc55oVv7nygGnfI9bhLCLzUEs4794y0Bkqx4q2zy7Y= @@ -391,29 +549,38 @@ github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats= +github.com/sigstore/rekor v1.5.3/go.mod h1:h3GK5dDqCcWJJZUJwdpKGSSmEV2GEjPUjJy3WTjBwzA= +github.com/sigstore/rekor-tiles/v2 v2.3.0/go.mod h1:DEFiKSyQ4nF75QRVNdOPaIH3cmvMkO2B6xDZjNYngPc= +github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M= +github.com/sigstore/sigstore-go v1.2.2/go.mod h1:MIFwBxAHJD+/lKgZzt9n/4Zhq/3T2+EuGX8iGrIsZgU= +github.com/sigstore/timestamp-authority/v2 v2.1.2/go.mod h1:o6rAVZceFyejClIj/uStRNIemP16bVMZtbMmhk6pr0U= github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc= github.com/sirupsen/logrus v1.10.0/go.mod h1:FXZFonkDAnFozmO+5hGAFvB0Yg9/j2SIhA/QuIkP180= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= +github.com/spdx/tools-golang v0.5.7/go.mod h1:jg7w0LOpoNAw6OxKEzCoqPC2GCTj45LyTlVmXubDsYw= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI= github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= -github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= @@ -425,10 +592,20 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/timandy/routine v1.1.6/go.mod h1:kXslgIosdY8LW0byTyPnenDgn4/azt2euufAq9rK51w= github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323/go.mod h1:3Iuxbr0P7D3zUzBMAZB+ois3h/et0shEz0qApgHYGpY= +github.com/tonistiigi/fsutil v0.0.0-20260717003753-6d9dc2ebad62/go.mod h1:K5zrLch9UaSGNiek5XHZeqZUf1zPWJHqDfLIcnpquQ4= +github.com/tonistiigi/go-actions-cache v0.0.0-20260120203934-54bc28c26fd2/go.mod h1:cD0SB2270BYw6HYKriFn4H6NRLhGj6ytf48YTpsm8LY= +github.com/tonistiigi/go-archvariant v1.0.0/go.mod h1:TxFmO5VS6vMq2kvs3ht04iPXtu2rUT/erOnGFYfk5Ho= +github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0/go.mod h1:278M4p8WsNh3n4a1eqiFcV2FGk7wE5fwUpUom9mK9lE= +github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea/go.mod h1:WPnis/6cRcDZSUvVmezrxJPkiO87ThFYsoUiMwWNDJk= +github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab/go.mod h1:ulncasL3N9uLrVann0m+CDlJKWsIAP34MPcOJF6VRvc= +github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= github.com/tree-sitter-grammars/tree-sitter-kotlin v1.1.0 h1:SWIUDASa+WPhDDem1U5IJpYwQEezkqXrUI61OcnORzM= github.com/tree-sitter-grammars/tree-sitter-kotlin v1.1.0/go.mod h1:eH+flFf3QOa9c9BY9g3Bz02F7zTq30kGIG3cgB0lSlI= github.com/tree-sitter/go-tree-sitter v0.25.0 h1:sx6kcg8raRFCvc9BnXglke6axya12krCJF5xJ2sftRU= @@ -463,6 +640,10 @@ github.com/tree-sitter/tree-sitter-typescript v0.23.2 h1:/Odvphn18PniVixb9e97X0D github.com/tree-sitter/tree-sitter-typescript v0.23.2/go.mod h1:zjzMXT/Ulffel2xfOcAkQQkiAkmgnbtPGlFQw/5X4xA= github.com/ulikunitz/xz v0.5.17 h1:flR0y/x1hgM8EGV1AW3Xll6T413G0glV8UfBwR617V4= github.com/ulikunitz/xz v0.5.17/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= +github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/vbatts/tar-split v0.12.3/go.mod h1:sQOc6OlqGCr7HkGx/IDBeKiTIvqhmj8KffNhEXG4Nq0= +github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= @@ -478,30 +659,41 @@ github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yoheimuta/go-protoparser/v4 v4.14.2 h1:/P/LlX1CF9NaTWEltGcIZVvNlPbhABuAnBtAWpb3+74= github.com/yoheimuta/go-protoparser/v4 v4.14.2/go.mod h1:AHNNnSWnb0UoL4QgHPiOAg2BniQceFscPI5X/BZNHl8= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= +go.etcd.io/gofail v0.2.0/go.mod h1:nL3ILMGfkXTekKI3clMBNazKnjUZjYLKmBHzsVAnC1o= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 h1:Tq+E65HaNbZTeqcLw6QUhsV+/EIWdkfUH2CQzcrlE5M= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0/go.mod h1:A+EVhDakAj1waJBPhmTszZaSr08ww8MRRKy1nB1rqck= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0/go.mod h1:3jnStNwSufK+f5ktjL4EPcwtig4rtd81NS70lqHuXl8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8= +go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 h1:N3YQCxjxQ/bMjyc3heladfRm9t9RTksGQH8z4w6yU/0= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0/go.mod h1:Mp8HOFqcaUyypCuGv9IhDdTHnJ56lSudSHMd+pVSCEA= go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= @@ -518,6 +710,7 @@ go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6Tb go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -533,6 +726,7 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -542,6 +736,7 @@ golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/perf v0.0.0-20250813145418-2f7363a06fe1/go.mod h1:rjfRjhHXb3XNVh/9i5Jr2tXoTd0vOlZN5rzsM8cQE6k= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -572,14 +767,19 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= -google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/api v0.278.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= +google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b h1:nB3spRIaSPtxJpxb0k41yljROvm2Jrwfs2pSNdKS5y8= +google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b/go.mod h1:9N4vtjEwvgQKEdlFVQO0M44ErhecQ9DCSQfGoZ51M6w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 h1:QG6hS4zFplRZvOQedzei4t2pT/zkQc3VR1u+S9/rcAw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1/go.mod h1:YNKnb2OAApgYn2oYY47Rn7alMr1zWjb2U8Q0aoGWiNc= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -598,8 +798,15 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks= +k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +kernel.org/pub/linux/libs/security/libcap/cap v1.2.78/go.mod h1:VjuVda6m2qGkpCVfrFkpTGyvkdlZ2N5/yfo89tujlg8= +kernel.org/pub/linux/libs/security/libcap/psx v1.2.78/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= +mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo= oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= @@ -610,5 +817,8 @@ sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7 sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.4.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +tags.cncf.io/container-device-interface v1.1.0/go.mod h1:76Oj0Yqp9FwTx/pySDc8Bxjpg+VqXfDb50cKAXVJ34Q= +tags.cncf.io/container-device-interface/specs-go v1.1.0/go.mod h1:u86hoFWqnh3hWz3esofRFKbI261bUlvUfLKGrDhJkgQ= diff --git a/pkg/cli/display.go b/pkg/cli/display.go index 1b5976ccd..e3d1c24e3 100644 --- a/pkg/cli/display.go +++ b/pkg/cli/display.go @@ -301,15 +301,29 @@ func printErrorChain(err error) { // which case it is the innermost layer above the exit, the one that ran the // command and names what it was doing. An error that is nothing but an exit // status keeps it. +// +// A sentinel is not that layer either. fmt.Errorf("%w: reason", ErrX) puts +// the sentinel innermost and its reason in the layer above, so showing the +// innermost layer alone would print the refusal's class ("module contract +// negotiation failed") and drop the reason it names. When the layer above +// the root reads "root: …", that layer is shown whole. func actionableRoot(err error) string { layers := unwrapErrorLayers(err) - i := 0 - for cur := err; cur != nil; cur, i = errors.Unwrap(cur), i+1 { + var chain []error + for cur := err; cur != nil; cur = errors.Unwrap(cur) { + chain = append(chain, cur) + } + root := len(chain) - 1 + for i, cur := range chain { if _, exit := cur.(*exec.ExitError); exit && i > 0 { - return layers[i-1] + root = i - 1 + break } } - return layers[len(layers)-1] + if root > 0 && strings.HasPrefix(chain[root-1].Error(), chain[root].Error()+": ") { + return layers[root-1] + } + return layers[root] } // unwrapErrorLayers walks the Unwrap chain and returns each layer's diff --git a/pkg/cli/display_test.go b/pkg/cli/display_test.go index 003d6d5cf..3d7982291 100644 --- a/pkg/cli/display_test.go +++ b/pkg/cli/display_test.go @@ -257,3 +257,20 @@ func TestActionableRootIsTheInnermostLayerOtherwise(t *testing.T) { t.Errorf("got %q, want the innermost layer", got) } } + +// A sentinel wrapped with its reason ("%w: reason") is the innermost layer, +// but it names only the refusal's class: plain mode shows the layer that +// carries the reason, so the refusal never reaches the user without it. +func TestActionableRootKeepsTheReasonASentinelWasWrappedWith(t *testing.T) { + sentinel := errors.New("module contract negotiation failed") + reason := fmt.Errorf("%w: contract path %q for api/http is not under an artifact root", sentinel, "contracts/api/api/http/openapi.json") + top := fmt.Errorf("module billing: %w", reason) + got := actionableRoot(top) + want := `module contract negotiation failed: contract path "contracts/api/api/http/openapi.json" for api/http is not under an artifact root` + if got != want { + t.Errorf("\ngot: %q\nwant: %q", got, want) + } + if got := actionableRoot(reason); got != want { + t.Errorf("unwrapped reason: got %q, want %q", got, want) + } +} diff --git a/pkg/cli/file_logger.go b/pkg/cli/file_logger.go index c05aaba01..2fc82919b 100644 --- a/pkg/cli/file_logger.go +++ b/pkg/cli/file_logger.go @@ -8,10 +8,12 @@ import ( "sync" "time" + "github.com/codefly-dev/core/agents" + "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/wool" ) -// FileLogger writes JSON log entries to date-stamped files in a directory. +// FileLogger owns one invocation directory and its dated JSON log segments. // Rotates when file exceeds maxSize bytes. type FileLogger struct { mu sync.Mutex @@ -20,18 +22,25 @@ type FileLogger struct { file *os.File size int64 date string // current date for rotation + segment uint64 + closed bool } const defaultMaxSize = 50 * 1024 * 1024 // 50MB -// NewFileLogger creates a file logger writing to dir/.log. -// Rotates daily and when file exceeds maxSize. +// NewFileLogger allocates an exclusive invocation directory under dir. +// Every rotation remains in that directory, including late shutdown records. func NewFileLogger(dir string) (*FileLogger, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("cannot create log directory: %w", err) } - fl := &FileLogger{dir: dir, maxSize: defaultMaxSize} + invocation, err := os.MkdirTemp(dir, "run-"+time.Now().UTC().Format("2006-01-02T150405.000000000Z")+"-") + if err != nil { + return nil, fmt.Errorf("cannot create invocation log directory: %w", err) + } + fl := &FileLogger{dir: invocation, maxSize: defaultMaxSize} if err := fl.rotate(); err != nil { + _ = os.Remove(invocation) // Only the empty directory allocated above. return nil, err } return fl, nil @@ -42,29 +51,18 @@ func (fl *FileLogger) rotate() error { if fl.file != nil && fl.date == today && (fl.maxSize == 0 || fl.size < fl.maxSize) { return nil } + name := filepath.Join(fl.dir, fmt.Sprintf("%s-%06d.log", today, fl.segment)) + f, err := os.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("cannot open log file: %w", err) + } if fl.file != nil { if err := fl.file.Close(); err != nil { fmt.Fprintf(os.Stderr, "warning: cannot close log file: %v\n", err) } } - fl.date = today - name := filepath.Join(fl.dir, today+".log") - - // If file is too big, add a suffix - info, err := os.Stat(name) - if err == nil && fl.maxSize > 0 && info.Size() >= fl.maxSize { - name = filepath.Join(fl.dir, fmt.Sprintf("%s-%d.log", today, time.Now().Unix())) - } - - f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - return fmt.Errorf("cannot open log file: %w", err) - } - info, _ = f.Stat() - if info != nil { - fl.size = info.Size() - } - fl.file = f + fl.file, fl.date, fl.size = f, today, 0 + fl.segment++ return nil } @@ -84,6 +82,9 @@ func (fl *FileLogger) Process(log *wool.Log) { func (fl *FileLogger) ProcessWithSource(source *wool.Identifier, log *wool.Log) { fl.mu.Lock() defer fl.mu.Unlock() + if fl.closed { + return + } if err := fl.rotate(); err != nil { fmt.Fprintf(os.Stderr, "warning: log rotation failed: %v\n", err) @@ -116,6 +117,10 @@ func (fl *FileLogger) ProcessWithSource(source *wool.Identifier, log *wool.Log) func (fl *FileLogger) Close() error { fl.mu.Lock() defer fl.mu.Unlock() + if fl.closed { + return nil + } + fl.closed = true if fl.file != nil { return fl.file.Close() } @@ -124,32 +129,37 @@ func (fl *FileLogger) Close() error { // --- Global file logger setup --- -var globalFileLogger *FileLogger +var ( + fileLoggerMu sync.Mutex + globalFileLogger *FileLogger +) -// EnableFileLogging starts writing logs to ~/.codefly/logs/.log +// EnableFileLogging registers one sink for the CLI invocation, including all +// helper contexts and final teardown. The process owns its descriptor lifetime. func EnableFileLogging() { - home, err := os.UserHomeDir() - if err != nil { + fileLoggerMu.Lock() + defer fileLoggerMu.Unlock() + if globalFileLogger != nil { return } - dir := filepath.Join(home, ".codefly", "logs") - fl, err := NewFileLogger(dir) + fl, err := NewFileLogger(LogsDir()) if err != nil { fmt.Fprintf(os.Stderr, "warning: cannot enable file logging: %v\n", err) return } globalFileLogger = fl - // Register as an agent log processor too - // (agents.AddProcessor is called in logger.go init) + agents.AddProcessor(fl) } -// GetFileLogger returns the global file logger (nil if not enabled). +// GetFileLogger returns the invocation's sink (nil if not enabled). func GetFileLogger() *FileLogger { + fileLoggerMu.Lock() + defer fileLoggerMu.Unlock() return globalFileLogger } -// Path returns the absolute path of the file currently being written, or "" -// if the logger isn't open. +// Path returns the current segment, retaining the final path after Close so +// a failure report can still name it. An uninitialized logger returns "". func (fl *FileLogger) Path() string { fl.mu.Lock() defer fl.mu.Unlock() @@ -162,20 +172,13 @@ func (fl *FileLogger) Path() string { // LogFilePath returns the active log file path (or "" when file logging is off). // Used to point users at the detailed log on failure. func LogFilePath() string { - if globalFileLogger == nil { + fl := GetFileLogger() + if fl == nil { return "" } - return globalFileLogger.Path() + return fl.Path() } -// LogsDir returns the directory codefly writes its logs to (~/.codefly/logs), -// or "" if the home directory can't be resolved. Standalone — does not enable -// file logging, so the `codefly logs` command can read existing logs without -// creating a new file. -func LogsDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".codefly", "logs") -} +// LogsDir resolves the existing Codefly home policy without creating a logger. +// Read-only log commands must not allocate a new invocation. +func LogsDir() string { return filepath.Join(resources.CodeflyHomeDir(), "logs") } diff --git a/pkg/cli/file_logger_process_test.go b/pkg/cli/file_logger_process_test.go new file mode 100644 index 000000000..069e163d9 --- /dev/null +++ b/pkg/cli/file_logger_process_test.go @@ -0,0 +1,99 @@ +package cli + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/core/wool" + "github.com/stretchr/testify/require" +) + +func TestInvocationLogProcess(t *testing.T) { + if os.Getenv("CODEFLY_LOG_PROCESS_TEST") != "1" { + return + } + EnableFileLogging() + fl := GetFileLogger() + require.NotNil(t, fl) + defer fl.Close() + fl.Process(&wool.Log{Level: wool.INFO, Message: os.Getenv("CODEFLY_LOG_PROCESS_NAME") + " preparation"}) + fmt.Println(fl.Path()) + fl.maxSize = 1 // Force every later record into a new segment. + input := bufio.NewScanner(os.Stdin) + for input.Scan() { + fl.Process(&wool.Log{Level: wool.INFO, Message: input.Text()}) + } + require.NoError(t, input.Err()) +} + +func TestSeparateProcessesKeepLateShutdownInTheOldInvocation(t *testing.T) { + root := t.TempDir() + type child struct { + cmd *exec.Cmd + stdin io.WriteCloser + path string + stderr bytes.Buffer + waited bool + } + start := func(name string) *child { + c := &child{cmd: exec.Command(os.Args[0], "-test.run=^TestInvocationLogProcess$")} + c.cmd.Env = append(os.Environ(), "CODEFLY_HOME="+root, "CODEFLY_LOG_PROCESS_TEST=1", "CODEFLY_LOG_PROCESS_NAME="+name) + var err error + c.stdin, err = c.cmd.StdinPipe() + require.NoError(t, err) + stdout, err := c.cmd.StdoutPipe() + require.NoError(t, err) + c.cmd.Stderr = &c.stderr + require.NoError(t, c.cmd.Start()) + t.Cleanup(func() { + _ = c.stdin.Close() + if !c.waited { + _ = c.cmd.Process.Kill() + _ = c.cmd.Wait() + } + }) + scanner := bufio.NewScanner(stdout) + require.True(t, scanner.Scan(), "child must report its allocated log") + c.path = scanner.Text() + return c + } + old := start("old") + fresh := start("fresh") + require.NotEqual(t, filepath.Dir(old.path), filepath.Dir(fresh.path)) + _, err := fmt.Fprintln(fresh.stdin, "fresh running") + require.NoError(t, err) + _, err = fmt.Fprintln(old.stdin, "old late shutdown\nold final shutdown") + require.NoError(t, err) + for _, c := range []*child{old, fresh} { + require.NoError(t, c.stdin.Close()) + err := c.cmd.Wait() + c.waited = true + require.NoError(t, err, c.stderr.String()) + } + contents := func(path string) string { + files, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*.log")) + require.NoError(t, err) + var data strings.Builder + for _, file := range files { + b, err := os.ReadFile(file) + require.NoError(t, err) + data.Write(b) + } + return data.String() + } + oldLog, freshLog := contents(old.path), contents(fresh.path) + require.Contains(t, oldLog, "old preparation") + require.Contains(t, oldLog, "old late shutdown") + require.Contains(t, oldLog, "old final shutdown") + require.NotContains(t, oldLog, "fresh ") + require.Contains(t, freshLog, "fresh preparation") + require.Contains(t, freshLog, "fresh running") + require.NotContains(t, freshLog, "old ") +} diff --git a/pkg/cli/file_logger_test.go b/pkg/cli/file_logger_test.go index db4bab7b2..77a36c9b1 100644 --- a/pkg/cli/file_logger_test.go +++ b/pkg/cli/file_logger_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/codefly-dev/core/wool" @@ -56,3 +57,79 @@ func readLastEntry(t *testing.T, path string) fileEntry { } return entry } + +func TestIndependentInvocationsKeepTheirOwnLogFiles(t *testing.T) { + root := t.TempDir() + old, err := NewFileLogger(root) + if err != nil { + t.Fatal(err) + } + defer old.Close() + old.Process(&wool.Log{Level: wool.INFO, Message: "old preparation"}) + fresh, err := NewFileLogger(root) + if err != nil { + t.Fatal(err) + } + defer fresh.Close() + fresh.Process(&wool.Log{Level: wool.INFO, Message: "fresh startup"}) + old.Process(&wool.Log{Level: wool.INFO, Message: "old shutdown"}) + if old.Path() == fresh.Path() { + t.Fatal("independent invocations share the same backing file") + } + data, err := os.ReadFile(fresh.Path()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "old ") || !strings.Contains(string(data), "fresh startup") { + t.Fatalf("new invocation contains another run's records: %s", data) + } + // Rapid repeated rotations must remain in the old invocation, never reuse + // a shared date/second segment or change the fresh invocation's file. + old.maxSize = 1 + paths := map[string]bool{old.Path(): true} + for range 3 { + old.Process(&wool.Log{Level: wool.INFO, Message: "old rotated shutdown"}) + if paths[old.Path()] { + t.Fatal("rotation reused a full log segment") + } + paths[old.Path()] = true + } + updated, err := os.ReadFile(fresh.Path()) + if err != nil { + t.Fatal(err) + } + if string(updated) != string(data) { + t.Fatal("old rotation changed the fresh invocation's log") + } +} + +func TestClosedFileLoggerNeverReopensOrChangesItsReportedPath(t *testing.T) { + fl, err := NewFileLogger(t.TempDir()) + if err != nil { + t.Fatal(err) + } + fl.Process(&wool.Log{Level: wool.INFO, Message: "final shutdown"}) + path := fl.Path() + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := fl.Close(); err != nil { + t.Fatal(err) + } + if err := fl.Close(); err != nil { + t.Fatalf("second close: %v", err) + } + fl.maxSize = 1 + fl.Process(&wool.Log{Level: wool.INFO, Message: "late record"}) + if fl.Path() != path { + t.Fatal("closed logger reopened on rotation") + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("closed logger accepted a late record") + } +} diff --git a/pkg/composition/filelock.go b/pkg/composition/filelock.go index b4a1a976a..90849593c 100644 --- a/pkg/composition/filelock.go +++ b/pkg/composition/filelock.go @@ -25,26 +25,31 @@ var ErrLockTimeout = errors.New("timed out acquiring lock") // their own, so two concurrent `codefly run` processes cannot silently // clobber each other's update. func WithFileLock(lockPath string, timeout time.Duration, fn func() error) error { + return WithFileLockContext(context.Background(), lockPath, timeout, fn) +} + +// WithFileLockContext is WithFileLock with caller cancellation. The timeout +// bounds acquisition only; caller cancellation remains distinguishable from it. +func WithFileLockContext(parent context.Context, lockPath string, timeout time.Duration, fn func() error) error { + if err := parent.Err(); err != nil { + return err + } if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { return fmt.Errorf("create lock directory: %w", err) } lock := flock.New(lockPath) - ctx, cancel := context.WithTimeout(context.Background(), timeout) + ctx, cancel := context.WithTimeout(parent, timeout) defer cancel() locked, err := lock.TryLockContext(ctx, 50*time.Millisecond) - // A deadline here is this function's own timeout expiring — the context is - // derived from Background, never from the caller — so it means the holder - // outlasted the wait, not that anything is wrong with the lock. It arrives as - // an error rather than locked=false, so both spellings map to ErrLockTimeout. + defer func() { _ = lock.Close() }() + if parent.Err() != nil { + return parent.Err() + } if errors.Is(err, context.DeadlineExceeded) || (err == nil && !locked) { return fmt.Errorf("%w %s", ErrLockTimeout, lockPath) } if err != nil { return fmt.Errorf("acquire lock %s: %w", lockPath, err) } - defer func() { - _ = lock.Unlock() - _ = lock.Close() - }() return fn() } diff --git a/pkg/composition/filelock_test.go b/pkg/composition/filelock_test.go index 165a7bc42..b94cd6756 100644 --- a/pkg/composition/filelock_test.go +++ b/pkg/composition/filelock_test.go @@ -1,6 +1,8 @@ package composition import ( + "context" + "errors" "path/filepath" "sync" "testing" @@ -55,3 +57,32 @@ func TestWithFileLockSerializesConcurrentCallers(t *testing.T) { t.Fatalf("critical sections overlapped: first ran %v-%v, second started at %v", first.start, first.end, second.start) } } + +func TestWithFileLockContextDistinguishesCallerAndAcquisitionTimeout(t *testing.T) { + path := filepath.Join(t.TempDir(), "held.lock") + entered, release, done := make(chan struct{}), make(chan struct{}), make(chan error, 1) + go func() { + done <- WithFileLock(path, time.Second, func() error { close(entered); <-release; return nil }) + }() + <-entered + defer func() { + close(release) + if err := <-done; err != nil { + t.Error(err) + } + }() + unexpected := func() error { t.Error("entered held critical section"); return nil } + if err := WithFileLockContext(t.Context(), path, 10*time.Millisecond, unexpected); !errors.Is(err, ErrLockTimeout) { + t.Fatalf("acquisition timeout: %v", err) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond) + defer cancel() + if err := WithFileLockContext(ctx, path, time.Second, unexpected); !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrLockTimeout) { + t.Fatalf("caller deadline: %v", err) + } + canceled, stop := context.WithCancel(t.Context()) + stop() + if err := WithFileLockContext(canceled, path, time.Second, unexpected); !errors.Is(err, context.Canceled) { + t.Fatalf("caller cancellation: %v", err) + } +} diff --git a/pkg/composition/materialize.go b/pkg/composition/materialize.go index e11a58ac5..52e43a000 100644 --- a/pkg/composition/materialize.go +++ b/pkg/composition/materialize.go @@ -466,10 +466,7 @@ func pinnedRequestsAnswered(ctx context.Context, workspace *resources.Workspace) if !pinnedManaged(ref, directive, receipt.ResolvedPath(), roots.owned...) { continue } - if directive == nil || directive.Path == "" || directive.Path != receipt.ResolvedPath() { - return false, nil - } - if !receipt.Answers(ref, ResolutionModeFor(ref, directive, receipt, declared[ref.Name])) { + if !moduleReceiptAnswers(ref, directive, receipt, ResolutionModeFor(ref, directive, receipt, declared[ref.Name])) { return false, nil } // The receipt answers the request, but only where the materialization it @@ -499,16 +496,7 @@ func pinnedRequestsAnswered(ctx context.Context, workspace *resources.Workspace) if !serviceManaged(serviceDirective, receipt.ResolvedPath(), roots.owned...) { continue } - // A directive still naming a version has not been materialized into a - // path yet, whatever a receipt records. - if serviceDirective.Version != "" || serviceDirective.Path != receipt.ResolvedPath() { - return false, nil - } - // The request itself lives only on the receipt, so there is no second - // copy to compare it against. What can still have moved underneath it - // is the module identity the package was pulled from, and the mode it - // was pulled in. - if receipt.Source != ref.Source || receipt.Module != ref.Module || receipt.Mode != moduleMode { + if !serviceReceiptAnswers(ref, serviceDirective, receipt, moduleMode) { return false, nil } // Same gate as the module loop above: a path under a root that is no diff --git a/pkg/composition/source.go b/pkg/composition/source.go new file mode 100644 index 000000000..e4aa5ee7e --- /dev/null +++ b/pkg/composition/source.go @@ -0,0 +1,57 @@ +package composition + +import ( + "context" + "fmt" + + "github.com/codefly-dev/core/resources" +) + +// ServiceUsesSource distinguishes a developer checkout or materialized clone +// from a published package. Core resolves both materializations to paths; the +// existing materialization receipts preserve how those paths were selected. +// This only reads provenance: it never pulls or changes a resolution. +func ServiceUsesSource(ctx context.Context, workspace *resources.Workspace, module, service string) (bool, error) { + state, err := loadMaterializationState(ctx, workspace) + if err != nil { + return false, err + } + for _, ref := range workspace.Modules { + if ref.Name != module { + continue + } + var directive *resources.ModuleResolveDirective + if state.overlay != nil { + directive = state.overlay.Resolve[module] + } + receipt := state.receipts[module] + mode := ResolutionModeFor(ref, directive, receipt, state.declared[module]) + if directive != nil { + if override := directive.Services[service]; override != nil { + managed := serviceManaged(override, state.receipts[serviceReceiptKey(module, service)].ResolvedPath(), state.roots.owned...) + if managed && !serviceReceiptAnswers(ref, override, state.receipts[serviceReceiptKey(module, service)], mode) { + return false, fmt.Errorf("service %s/%s materialization is unresolved or stale; resolve the composition before checking source freshness", module, service) + } + return !managed || mode.Unverified(), nil + } + } + managed := pinnedManaged(ref, directive, receipt.ResolvedPath(), state.roots.owned...) + if managed && !moduleReceiptAnswers(ref, directive, receipt, mode) { + return false, fmt.Errorf("module %s materialization is unresolved or stale; resolve the composition before checking source freshness", module) + } + return !managed || mode.Unverified(), nil + } + return false, fmt.Errorf("module %s is not composed in workspace %s", module, workspace.Name) +} + +// These are the same request/receipt checks used by the materializer's +// answered-state gate. A desired mode alone cannot describe selected bytes. +func moduleReceiptAnswers(ref *resources.ModuleReference, directive *resources.ModuleResolveDirective, receipt *ResolutionReceipt, mode ResolutionMode) bool { + return directive != nil && directive.Path != "" && directive.Path == receipt.ResolvedPath() && receipt.Answers(ref, mode) +} + +func serviceReceiptAnswers(ref *resources.ModuleReference, directive *resources.ServiceResolveDirective, receipt *ResolutionReceipt, mode ResolutionMode) bool { + // A service's requested version lives only in its receipt after materialization; + // the module identity and mode still have independent declarations to compare. + return directive != nil && directive.Version == "" && directive.Path != "" && directive.Path == receipt.ResolvedPath() && receipt != nil && receipt.Source == ref.Source && receipt.Module == ref.Module && receipt.Mode == mode +} diff --git a/pkg/composition/source_test.go b/pkg/composition/source_test.go new file mode 100644 index 000000000..aef9fffbd --- /dev/null +++ b/pkg/composition/source_test.go @@ -0,0 +1,61 @@ +package composition + +import ( + "os" + "path/filepath" + "testing" + + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" +) + +func TestServiceUsesSourceRetainsMaterializationProvenance(t *testing.T) { + for _, tc := range []struct { + name, overlay, receipt, serviceReceipt, version string + err string + mode ResolutionMode + source bool + }{ + {name: "in repository", source: true}, + {name: "unmaterialized package", version: "1.0.0", err: "unresolved or stale"}, + {name: "developer path", version: "1.0.0", overlay: " path: ./checkout\n", source: true}, + {name: "materialized package", version: "1.0.0", overlay: " path: ./checkout\n", receipt: "./checkout", mode: ResolutionModeVerified}, + {name: "materialized git", version: "1.0.0", overlay: " path: ./checkout\n", receipt: "./checkout", mode: ResolutionModeGit, source: true}, + {name: "commit clone", version: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", overlay: " path: ./checkout\n", receipt: "./checkout", mode: ResolutionModeCommit, source: true}, + {name: "local service in package", version: "1.0.0", overlay: " path: ./checkout\n services:\n api:\n path: ./editing-api\n", receipt: "./checkout", mode: ResolutionModeVerified, source: true}, + {name: "pinned service in local module", version: "1.0.0", overlay: " path: ./checkout\n services:\n api:\n version: 2.0.0\n", err: "unresolved or stale"}, + {name: "materialized service in local module", version: "1.0.0", overlay: " path: ./checkout\n services:\n api:\n path: ./package-api\n", serviceReceipt: "./package-api", mode: ResolutionModeVerified}, + {name: "removed declared git cannot become verified", version: "1.0.0", overlay: " path: ./checkout\n", receipt: "./checkout", mode: ResolutionModeDeclaredGit, err: "unresolved or stale"}, + {name: "old receipt does not own new path", version: "1.0.0", overlay: " path: ./editing\n", receipt: "./checkout", mode: ResolutionModeVerified, source: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(resources.CodeflyHomeEnv, t.TempDir()) + dir := t.TempDir() + ref := &resources.ModuleReference{Name: "shop", Version: tc.version} + if tc.version != "" { + ref.Source = "github.com/example/shop" + } + workspace := &resources.Workspace{Name: "test", Modules: []*resources.ModuleReference{ref}} + workspace.WithDir(dir) + require.NoError(t, os.WriteFile(filepath.Join(dir, resources.WorkspaceConfigurationName), []byte("name: test\n"), 0644)) + if tc.overlay != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, resources.LocalOverlayConfigurationName), []byte("resolve:\n shop:\n"+tc.overlay), 0644)) + } + receipts := map[string]*ResolutionReceipt{} + if tc.receipt != "" { + receipts["shop"] = &ResolutionReceipt{Path: tc.receipt, Mode: tc.mode, Source: ref.Source, Requested: tc.version, Version: tc.version} + } + if tc.serviceReceipt != "" { + receipts["shop/api"] = &ResolutionReceipt{Path: tc.serviceReceipt, Mode: tc.mode, Service: "api", Source: ref.Source, Requested: "2.0.0", Version: "2.0.0"} + } + require.NoError(t, SaveResolutionReceipts(t.Context(), dir, receipts)) + got, err := ServiceUsesSource(t.Context(), workspace, "shop", "api") + if tc.err != "" { + require.ErrorContains(t, err, tc.err) + return + } + require.NoError(t, err) + require.Equal(t, tc.source, got) + }) + } +} diff --git a/pkg/control/deploy.go b/pkg/control/deploy.go index 5200f636a..23de86c9b 100644 --- a/pkg/control/deploy.go +++ b/pkg/control/deploy.go @@ -6,6 +6,7 @@ import ( "time" "github.com/codefly-dev/cli/pkg/deployments" + "github.com/codefly-dev/cli/pkg/gitops" "github.com/codefly-dev/cli/pkg/orchestration" ) @@ -69,6 +70,9 @@ func (p *planeImpl) runDeploy(ctx context.Context, req *DeployRequest) (DeployRe if err != nil { return DeployResult{}, fmt.Errorf("select environment %q: %w", envName, err) } + if err = gitops.CheckCompositionPermissions(ctx, ws, env, true, module.Name); err != nil { + return DeployResult{}, err + } var deploymentManager deployments.Manager var evidenceProvider deployments.EvidenceProvider if req.DryRun { diff --git a/pkg/control/lifecycle.go b/pkg/control/lifecycle.go index 7089ddd18..cff09727e 100644 --- a/pkg/control/lifecycle.go +++ b/pkg/control/lifecycle.go @@ -7,6 +7,7 @@ import ( "github.com/codefly-dev/cli/pkg/composition" "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/cli/pkg/gitops" "github.com/codefly-dev/cli/pkg/orchestration" "github.com/codefly-dev/cli/pkg/solutionrun" runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0" @@ -107,7 +108,8 @@ func (p *planeImpl) buildFlow(ctx context.Context, mode orchestration.Mode, name return nil, fmt.Errorf("select environment %q: %w", envName, err) } flow, err := orchestration.NewFlow(ctx, ws, module, service, env, mode, - orchestration.WithRunModuleClosure(moduleClosureSeeds(mode, module)...)) + orchestration.WithRunModuleClosure(moduleClosureSeeds(mode, module)...), + orchestration.WithCompositionCheck(gitops.PermissionPreflight(mode != orchestration.RunMode && mode != orchestration.TestMode))) if err != nil { return nil, fmt.Errorf("create flow: %w", err) } diff --git a/pkg/deploysecrets/plan.go b/pkg/deploysecrets/plan.go index e36a31d13..d794b98e8 100644 --- a/pkg/deploysecrets/plan.go +++ b/pkg/deploysecrets/plan.go @@ -662,7 +662,7 @@ func fallbackSource(property gitops.RenderedSecretProperty, generators map[strin Source: "no key holds it, no configuration file supplies it, and service-secrets.generate declares no generator for " + strings.Join(property.Keys, ", ")}, "", nil } - format, size := generatorFormat(&generator) + format, size := generator.Encoding() // One value per stored key, so every remote key reading that key is handed // the same one: a configuration value is one value however many properties // file it. @@ -679,21 +679,6 @@ func fallbackSource(property gitops.RenderedSecretProperty, generators map[strin Source: fmt.Sprintf("service-secrets.generate %s/%s for %s (%s, %d bytes)", generator.Scope, generator.Configuration, declaredKey, format, size)}, value, nil } -func generatorFormat(generator *environments.EnvironmentSecretGenerator) (secretgen.Format, int) { - format := secretgen.Format(generator.Format) - if format == "" { - format = secretgen.FormatHex - } - size := generator.Bytes - if size == 0 { - size = 32 - if format == secretgen.FormatIdentifier { - size = 12 - } - } - return format, size -} - // generatorIndex maps every stored key a generator covers to it. A key two // generators cover is refused: which format wins would be an accident. func generatorIndex(generators []environments.EnvironmentSecretGenerator, services []string) (map[string]environments.EnvironmentSecretGenerator, error) { @@ -783,8 +768,8 @@ func declaredGenerator(property gitops.RenderedSecretProperty, generators map[st if !declared { continue } - candidateFormat, candidateSize := generatorFormat(&candidate) - format, size := generatorFormat(&generator) + candidateFormat, candidateSize := candidate.Encoding() + format, size := generator.Encoding() if declaredKey != "" && (candidateFormat != format || candidateSize != size) { return "", environments.EnvironmentSecretGenerator{}, fmt.Errorf("property %s is read as both %s and %s, which service-secrets.generate declares differently", property.Property, declaredKey, key) diff --git a/pkg/deploysecrets/rotate.go b/pkg/deploysecrets/rotate.go index 70a2c3c37..7824fd77b 100644 --- a/pkg/deploysecrets/rotate.go +++ b/pkg/deploysecrets/rotate.go @@ -138,7 +138,7 @@ func planRotations(in *Inputs, states []*remoteState, generators map[string]envi case environments.SecretGeneratorFormatIdentifier: return nil, nil, fmt.Errorf("--rotate %s: it is an identifier (a user or database name, say); a new one in the store renames nothing on the server that owns it", key) } - format, size := generatorFormat(&generator) + format, size := generator.Encoding() value, err := secretgen.Generate(format, size) if err != nil { return nil, nil, err diff --git a/pkg/environments/cell_test.go b/pkg/environments/cell_test.go index 9b3edbe47..b0f621b77 100644 --- a/pkg/environments/cell_test.go +++ b/pkg/environments/cell_test.go @@ -94,3 +94,41 @@ service-mesh: t.Fatalf("a mistyped key is refused: %v", err) } } + +// The egress group a service receives is its own declaration, every port +// explicit and sorted, the list the cell file carries for it; a service the +// environment declares nothing for receives the group with HOSTS empty. +func TestEgressGroupCarriesExactlyTheServicesDeclaredHosts(t *testing.T) { + env := decodeEnvironment(t, `name: staging +egress: + shop/api: {hosts: [identity.example.test, {name: api.github.com, port: 8443}]} + shop/web: {hosts: [cdn.example.test]} +`) + hostsOf := func(env *Environment, module, service string) string { + t.Helper() + group := env.EgressGroup(module, service) + if len(group.Infos) != 1 || group.Infos[0].Name != EgressConfiguration || len(group.Infos[0].ConfigurationValues) != 1 { + t.Fatalf("group = %+v", group) + } + value := group.Infos[0].ConfigurationValues[0] + if value.Key != EgressHostsKey || value.Secret { + t.Fatalf("value = %+v", value) + } + return value.Value + } + if got := hostsOf(env, "shop", "api"); got != "api.github.com:8443,identity.example.test:443" { + t.Fatalf("shop/api HOSTS = %q", got) + } + if got := hostsOf(env, "shop", "web"); got != "cdn.example.test:443" { + t.Fatalf("shop/web HOSTS = %q", got) + } + for _, other := range [][2]string{{"shop", "worker"}, {"edge", "api"}} { + if got := hostsOf(env, other[0], other[1]); got != "" { + t.Fatalf("%s/%s declares no egress, HOSTS = %q", other[0], other[1], got) + } + } + var none *Environment + if got := hostsOf(none, "shop", "api"); got != "" { + t.Fatalf("no environment, HOSTS = %q", got) + } +} diff --git a/pkg/environments/environment.go b/pkg/environments/environment.go index aa430bac5..b9c81b0ee 100644 --- a/pkg/environments/environment.go +++ b/pkg/environments/environment.go @@ -11,6 +11,8 @@ import ( "time" "unicode" + "github.com/codefly-dev/cli/pkg/secretgen" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/resources" "github.com/distribution/reference" @@ -854,6 +856,14 @@ type EnvironmentServiceSecrets struct { MayBeEmpty []string `yaml:"may-be-empty,omitempty"` } +// Stored reports whether the block names a secret store, as a cluster's does. A +// block that declares only generators names none: its keys live in the +// workspace's own secret configurations, which a local run generates, and a +// render, which carries secrets only as references into a store, refuses it. +func (s *EnvironmentServiceSecrets) Stored() bool { + return s != nil && s.SecretStore != (EnvironmentSecretStoreReference{}) +} + // Secret generator scopes: which configuration a generator's keys belong to. const ( SecretGeneratorScopeWorkspace = "workspace" @@ -889,6 +899,24 @@ type EnvironmentSecretGenerator struct { Bytes int `yaml:"bytes,omitempty"` } +// Encoding is how the generator's values are minted: its format, hex by +// default, and its size in bytes, 32 by default (12 for an identifier). A local +// run and `codefly deploy secrets` mint through it alike. +func (generator *EnvironmentSecretGenerator) Encoding() (secretgen.Format, int) { + format := secretgen.Format(generator.Format) + if format == "" { + format = secretgen.FormatHex + } + size := generator.Bytes + if size == 0 { + size = 32 + if format == secretgen.FormatIdentifier { + size = 12 + } + } + return format, size +} + // UnmarshalYAML refuses an unknown key: a mistyped `keys` would otherwise leave a // generator that generates nothing, and the keys it meant are then reported as // ones to supply by hand. @@ -1121,6 +1149,14 @@ func (s *EnvironmentServiceSecrets) Validate() error { if s == nil { return nil } + if !s.Stored() && s.Defaults == nil && len(s.Services) == 0 && len(s.MayBeEmpty) == 0 { + // Generators alone: the store is the workspace's own secret + // configurations, which a local run writes (codefly run). + if len(s.Generate) == 0 { + return fmt.Errorf("service-secrets declares neither a secret-store nor a generator") + } + return s.validateGenerators() + } if err := s.SecretStore.validate("service-secrets secret-store"); err != nil { return err } @@ -1424,6 +1460,14 @@ type Environment struct { // (the same MODULE__OPERATION key generate runnable-bindings emits). // Installation input only: Runtime deliberately does not send it to agents. RunnableScopeSelections map[string]ScopeSelections `yaml:"runnable-scope-selections,omitempty"` + // RunnableCallEndpoint is the declared HTTP-based endpoint, + // "//", every call `generate runnable-bindings` + // prepares for this environment is sent to in place of the owner's + // listener: the composition's hop in front of its modules, say. Declared + // here, the prepared bindings and their --check are a function of the + // workspace; the command's --call-endpoint overrides it for one + // invocation. Installation input only, like RunnableScopeSelections. + RunnableCallEndpoint string `yaml:"runnable-call-endpoint,omitempty"` // Deploy-target overrides (CLI-side; not serialized to proto). // Empty values fall back to legacy defaults (local k3d, ~/.kube/config, @@ -1704,6 +1748,35 @@ func (env *Environment) EgressHosts(module, service string) []EnvironmentEgressH return hosts } +// EgressConfiguration is the workspace configuration group that hands a +// service its own declared egress: the hosts the environment's `egress:` +// block names for it — the list the cell file carries so the cell allows +// exactly those — so the service never keeps a copy of its own. The CLI +// generates it from that declaration, for a service that declares the group, +// on every path that delivers workspace configurations (a native or container +// run and a render alike); an authored file for it is refused. +const EgressConfiguration = "egress" + +// EgressHostsKey is the one value of EgressConfiguration: the service's +// declared hosts as comma-separated `host:port`, sorted, every port explicit; +// empty when the environment declares no egress for the service. +const EgressHostsKey = "HOSTS" + +// EgressGroup is the EgressConfiguration group one service receives. It is +// always the group, HOSTS empty when nothing is declared, so a service reads +// "this environment declares no egress for me" rather than an absent group. +func (env *Environment) EgressGroup(module, service string) *basev0.Configuration { + hosts := env.EgressHosts(module, service) + entries := make([]string, 0, len(hosts)) + for _, host := range hosts { + entries = append(entries, fmt.Sprintf("%s:%d", host.Name, host.Port)) + } + return &basev0.Configuration{Origin: resources.ConfigurationWorkspace, Infos: []*basev0.ConfigurationInformation{{ + Name: EgressConfiguration, + ConfigurationValues: []*basev0.ConfigurationValue{{Key: EgressHostsKey, Value: strings.Join(entries, ",")}}, + }}} +} + // EnvironmentCellWorkload is what the composition declares about one service // for the cell: the platform bindings it holds, cloud identity, and the // endpoints whose listener accepts only a protected waypoint's assertion of @@ -1850,3 +1923,12 @@ func (env *Environment) CellWorkload(module, service string) (EnvironmentCellWor sort.Strings(presenter) return EnvironmentCellWorkload{Bindings: bindings, CloudIdentity: workload.CloudIdentity, Presenter: presenter}, true } + +func (s *EnvironmentServiceSecrets) validateGenerators() error { + for index := range s.Generate { + if err := s.Generate[index].validate(index); err != nil { + return err + } + } + return nil +} diff --git a/pkg/environments/environment_test.go b/pkg/environments/environment_test.go index 9e6461e4d..b641f10cb 100644 --- a/pkg/environments/environment_test.go +++ b/pkg/environments/environment_test.go @@ -922,3 +922,41 @@ func loadDeploymentWorkspace(ctx context.Context, dir string) (*deploymentWorksp } return &deploymentWorkspace{Workspace: w, Environments: envs}, nil } + +// A local environment declares the secrets its run generates the way a cell's +// does, under service-secrets.generate, and names no store: its keys live in +// the workspace's own secret configurations. A block declaring neither a store +// nor a generator is refused. +func TestEnvironmentServiceSecretsGeneratorsAloneNameNoStore(t *testing.T) { + root := t.TempDir() + workspace := `name: platform +layout: modules +environments: + - name: local + service-secrets: + generate: + - scope: workspace + configuration: hop + keys: [PROOF] +` + if err := os.WriteFile(filepath.Join(root, resources.WorkspaceConfigurationName), []byte(workspace), 0o644); err != nil { + t.Fatal(err) + } + loaded, err := loadDeploymentWorkspace(context.Background(), root) + if err != nil { + t.Fatalf("generators alone: %v", err) + } + secrets := loaded.Environments[0].ServiceSecrets + if secrets.Stored() || len(secrets.Generate) != 1 { + t.Fatalf("loaded %+v", secrets) + } + if format, size := secrets.Generate[0].Encoding(); format != "hex" || size != 32 { + t.Fatalf("default encoding %s/%d", format, size) + } + if err := (&environments.EnvironmentServiceSecrets{}).Validate(); err == nil || !strings.Contains(err.Error(), "neither a secret-store nor a generator") { + t.Fatalf("an empty block: %v", err) + } + if !(&environments.EnvironmentServiceSecrets{SecretStore: environments.EnvironmentSecretStoreReference{Name: "cell", Kind: "ClusterSecretStore"}}).Stored() { + t.Fatal("a named store is stored") + } +} diff --git a/pkg/environments/module_authority.go b/pkg/environments/module_authority.go index 1079bab08..90a18e9c0 100644 --- a/pkg/environments/module_authority.go +++ b/pkg/environments/module_authority.go @@ -50,11 +50,13 @@ const ( ) // RenderOwnedWorkspaceConfiguration names the groups supplied per workload by -// the render (or by the native run standing in for it). They are never -// dependencies of the configuration manager and cannot be provided by -// authored workspace configuration files. +// the CLI rather than authored: by the render (or by the native run standing +// in for it), and EgressConfiguration by every delivery path from the +// environment's egress declaration. They are never dependencies of the +// configuration manager and cannot be provided by authored workspace +// configuration files. func RenderOwnedWorkspaceConfiguration(group string) bool { - return group == ModuleAuthorityConfiguration || group == ArtifactCacheConfiguration + return group == ModuleAuthorityConfiguration || group == ArtifactCacheConfiguration || group == EgressConfiguration } // OptionalWorkspaceConfiguration names the groups whose absence is itself an diff --git a/pkg/environments/runnable_scopes.go b/pkg/environments/runnable_scopes.go index f73efc905..d8f11034e 100644 --- a/pkg/environments/runnable_scopes.go +++ b/pkg/environments/runnable_scopes.go @@ -105,3 +105,14 @@ func (env *Environment) validateRunnableScopeSelections() error { } return nil } + +// validateRunnableCallEndpoint refuses a runnable-call-endpoint that is not a +// composition identity. Whether it names a declared HTTP-based endpoint is +// answered by generate runnable-bindings, which resolves it against the +// workspace. +func (env *Environment) validateRunnableCallEndpoint() error { + if env.RunnableCallEndpoint != "" && !deliveryPattern.MatchString(env.RunnableCallEndpoint) { + return fmt.Errorf("runnable-call-endpoint %q is not the composition identity // of a declared HTTP-based endpoint", env.RunnableCallEndpoint) + } + return nil +} diff --git a/pkg/environments/runnable_scopes_test.go b/pkg/environments/runnable_scopes_test.go index e41cc83a1..d49846bb5 100644 --- a/pkg/environments/runnable_scopes_test.go +++ b/pkg/environments/runnable_scopes_test.go @@ -117,3 +117,27 @@ func TestNullScopeSelectionIsRefused(t *testing.T) { }) } } + +// An environment declares where its prepared runnable calls are sent, so the +// bindings it prepares do not depend on a flag retyped per invocation. The +// declaration is installation input: it is never agent runtime configuration. +func TestAnEnvironmentDeclaresItsRunnableCallEndpoint(t *testing.T) { + var workspace resources.Workspace + require.NoError(t, yaml.Unmarshal([]byte("name: product\nenvironments:\n - name: local\n runnable-call-endpoint: authority/hop/http\n"), &workspace)) + env, err := environments.Select(&workspace, "local") + require.NoError(t, err) + require.Equal(t, "authority/hop/http", env.RunnableCallEndpoint) + require.Empty(t, env.Runtime().Extensions, "installation input is not agent runtime configuration") + resource, err := env.Resource() + require.NoError(t, err) + reloaded, err := environments.FromRuntime(resource) + require.NoError(t, err) + require.Equal(t, "authority/hop/http", reloaded.RunnableCallEndpoint) + + for _, bad := range []string{"authority/hop", "Authority/hop/http", "authority/hop/http/extra"} { + var malformed resources.Workspace + require.NoError(t, yaml.Unmarshal([]byte("name: product\nenvironments:\n - name: local\n runnable-call-endpoint: "+bad+"\n"), &malformed)) + _, err := environments.Select(&malformed, "local") + require.ErrorContains(t, err, "runnable-call-endpoint", bad) + } +} diff --git a/pkg/environments/workspace.go b/pkg/environments/workspace.go index af987820d..8e5807bbd 100644 --- a/pkg/environments/workspace.go +++ b/pkg/environments/workspace.go @@ -115,6 +115,9 @@ func (env *Environment) Validate() error { if err := env.validateRunnableScopeSelections(); err != nil { return err } + if err := env.validateRunnableCallEndpoint(); err != nil { + return err + } if err := env.ServiceConfig.Validate(); err != nil { return err } diff --git a/pkg/generators/client.go b/pkg/generators/client.go index 50967c596..f8dd56f94 100644 --- a/pkg/generators/client.go +++ b/pkg/generators/client.go @@ -11,6 +11,7 @@ import ( "sort" "strings" + hopv1 "github.com/codefly-dev/authority/libraries/hop/go/gen/authority/hop/v1" "github.com/codefly-dev/cli/pkg/cli" coreproto "github.com/codefly-dev/core/companions/proto" "github.com/codefly-dev/core/composition" @@ -302,15 +303,25 @@ func generateOneProtobufEntry(ctx context.Context, lang languages.Language, lang Module: moduleName, Services: entry.Services, Facade: facade, + GoRequires: linkedGoBindings(), + } + var set descriptorpb.FileDescriptorSet + if err := googleproto.Unmarshal(entry.ContractBytes, &set); err != nil { + return nil, fmt.Errorf("cannot parse contract descriptor set: %w", err) } var mapped []upstreamProtoModule - if len(entry.ProtoSources) > 0 { + if len(entry.ProtoSources) > 0 && !vendorsUpstreamModules(entry.ProtoSources, &set, lang) { + // Generated from sources, buf compiles only the entry's own files: the + // shared modules it imports are left to their distributions, exactly as + // when they are marked as imports below. A service that vendors an + // upstream module's files under its own proto tree (codefly/base, + // authority/hop) takes the descriptor path instead, where those files + // are marked as imports: compiled as sources they would be emitted + // again beside the module that owns them, and a binary linking both + // panics at init on the duplicate registration. req.Sources = entry.ProtoSources + mapped = upstreamModulesIn(&set, lang) } else { - var set descriptorpb.FileDescriptorSet - if err := googleproto.Unmarshal(entry.ContractBytes, &set); err != nil { - return nil, fmt.Errorf("cannot parse contract descriptor set: %w", err) - } image, marked, err := markUpstreamModulesAsImports(&set, lang) if err != nil { return nil, fmt.Errorf("cannot prepare contract descriptor set: %w", err) @@ -400,10 +411,12 @@ func (n bufModuleName) identity() string { return n.remote + "/" + n.owner + "/" + n.repository } -const ( - googleapisPyPI = "googleapis-common-protos" - protovalidatePyPI = "protovalidate" -) +// googleapisPyPI ships google/api, google/rpc and google/type. buf/validate +// has no PyPI distribution: `protovalidate` imports buf.validate without +// shipping it (1.x) or ships it under its own package (2.x). The proto +// companion's Python plugin drops an import used only for an option, which is +// all a client ever makes of buf/validate, so the bindings never import it. +const googleapisPyPI = "googleapis-common-protos" var ( googleapisModule = bufModuleName{remote: "buf.build", owner: "googleapis", repository: "googleapis"} @@ -428,7 +441,7 @@ var upstreamProtoModules = []upstreamProtoModule{ {pathPrefix: "google/api/", protoPackage: "google.api", bufModule: googleapisModule, pythonPackage: googleapisPyPI}, {pathPrefix: "google/rpc/", protoPackage: "google.rpc", bufModule: googleapisModule, pythonPackage: googleapisPyPI}, {pathPrefix: "google/type/", protoPackage: "google.type", bufModule: googleapisModule, pythonPackage: googleapisPyPI}, - {pathPrefix: "buf/validate/", protoPackage: "buf.validate", bufModule: protovalidateModule, pythonPackage: protovalidatePyPI}, + {pathPrefix: "buf/validate/", protoPackage: "buf.validate", bufModule: protovalidateModule}, } // buf marks a file that an image carries only to resolve imports with @@ -489,6 +502,58 @@ func markUpstreamModulesAsImports(set *descriptorpb.FileDescriptorSet, lang lang return data, marked, nil } +// linkedGoBindings are the Go packages a generated Go library imports rather +// than owns because a Go module publishes them: the hop's policy option, whose +// bindings this CLI links to read method policies. A contract file declaring +// that go_package is the hop's own file, vendored to compile, so the library +// imports it from there instead of registering a second copy (core's +// ClientRequest.GoRequires). Read from the linked descriptor, never retyped. +func linkedGoBindings() []string { + options, _ := hopv1.File_authority_hop_v1_policy_proto.Options().(*descriptorpb.FileOptions) + importPath, _, _ := strings.Cut(options.GetGoPackage(), ";") + if importPath == "" { + return nil + } + return []string{importPath} +} + +// vendorsUpstreamModules is whether any of sources is a file of an upstream +// proto module (by its path and package in set): a vendored copy, which must +// be generated as an import, never as the library's own file. +func vendorsUpstreamModules(sources []coreproto.Source, set *descriptorpb.FileDescriptorSet, lang languages.Language) bool { + own := make(map[string]bool, len(sources)) + for _, source := range sources { + own[filepath.ToSlash(source.Path)] = true + } + for _, file := range set.GetFile() { + if !own[file.GetName()] { + continue + } + if coreproto.IsForeignFile(file, lang, linkedGoBindings()...) { + return true + } + if _, found := upstreamModuleFor(file, lang); found { + return true + } + } + return false +} + +// upstreamModulesIn is the set of upstream proto modules set's files belong +// to, once each. +func upstreamModulesIn(set *descriptorpb.FileDescriptorSet, lang languages.Language) []upstreamProtoModule { + var modules []upstreamProtoModule + seen := map[string]bool{} + for _, file := range set.GetFile() { + module, found := upstreamModuleFor(file, lang) + if found && !seen[module.bufModule.identity()] { + seen[module.bufModule.identity()] = true + modules = append(modules, module) + } + } + return modules +} + func upstreamModuleFor(file *descriptorpb.FileDescriptorProto, lang languages.Language) (upstreamProtoModule, bool) { for _, module := range upstreamProtoModules { if !module.bufModule.isZero() && lang == languages.TYPESCRIPT { diff --git a/pkg/generators/client_library.go b/pkg/generators/client_library.go index fda872877..e04d9db7c 100644 --- a/pkg/generators/client_library.go +++ b/pkg/generators/client_library.go @@ -1,6 +1,7 @@ package generators import ( + "bufio" "bytes" "context" "fmt" @@ -167,12 +168,91 @@ func writeTypeScriptIndex(langDir string) error { } // wipePythonLibrary clears pkgDir's generated content once, before any entry -// is merged in. +// is merged in: _gen/, which is entirely its own, and each facade at the +// package root — a file whose generated-code notice names a protoc plugin, +// as the Python facade plugin's does. Every other file at the root is the +// developer's (a reader beside the facade, a helper the package exports, its +// __init__.py) and survives regeneration, exactly as the scaffold does: the +// package root is not a purely generated directory, so ownership is read from +// what a file says wrote it, never from its extension. func wipePythonLibrary(pkgDir string) error { if err := os.RemoveAll(filepath.Join(pkgDir, "_gen")); err != nil { return err } - return removeMatching(pkgDir, "*.py", "__init__.py") + entries, err := os.ReadDir(pkgDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + // Only a regular file can be a facade the plugin wrote; a link or a + // directory at the root is the developer's. + if !entry.Type().IsRegular() || filepath.Ext(entry.Name()) != ".py" { + continue + } + path := filepath.Join(pkgDir, entry.Name()) + generator, err := pythonNoticeGenerator(path) + if err != nil { + return err + } + if !strings.HasPrefix(generator, "protoc-gen-") { + continue + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +// pythonNoticeWindow bounds how much of a file is read for its leading notice. +const pythonNoticeWindow = 8 << 10 + +// pythonGeneratedNotice is a generated-code notice in either spelling a +// Python file carries one: the standard `# Code generated by . DO +// NOT EDIT.` comment, or the line the Python facade plugin writes in its +// module docstring, `generated by from ; do not edit`. +var pythonGeneratedNotice = regexp.MustCompile(`^(?:# Code generated by (\S.*?)\. DO NOT EDIT\.|generated by (\S+) from \S+; do not edit)$`) + +// pythonNoticeGenerator names the generator a Python file's notice says wrote +// it, "" when it carries none. Only the file's leading block is read — the +// comments and the module docstring it opens with — so a notice quoted in its +// body (a test asserting the facade's header, say) is never its own. +func pythonNoticeGenerator(path string) (string, error) { + file, err := os.Open(path) //nolint:gosec // path is a file directly in this command's own output package + if err != nil { + return "", err + } + defer file.Close() + scanner := bufio.NewScanner(io.LimitReader(file, pythonNoticeWindow)) + docstring := false + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if !docstring { + switch { + case line == "" || strings.HasPrefix(line, "#"): + case strings.HasPrefix(line, `"""`): + docstring, line = true, strings.TrimPrefix(line, `"""`) + default: + return "", nil // the first statement: the leading block is over + } + } + closing := false + if docstring { + if before, _, found := strings.Cut(line, `"""`); found { + line, closing = strings.TrimSpace(before), true + } + } + if match := pythonGeneratedNotice.FindStringSubmatch(line); match != nil { + return match[1] + match[2], nil + } + if closing { + return "", nil + } + } + return "", scanner.Err() } // mergePythonEntry moves raw's bindings under pkgDir/_gen/, hoists the facade @@ -201,12 +281,17 @@ func finalizePythonLibrary(langDir, pyPackage string, upstreamPackages []string) if err := writePythonScaffold(langDir, pyPackage); err != nil { return generatedLanguageExport{}, err } - if err := ensurePythonDependencies(langDir, upstreamPackages); err != nil { + if err := ensurePythonDependencies(langDir, append([]string{pythonGrpcRequirement}, upstreamPackages...)); err != nil { return generatedLanguageExport{}, err } return generatedLanguageExport{Name: "python", Path: "python/", Exports: []string{pyPackage}}, nil } +// pythonGrpcRequirement is the grpcio the generated `*_pb2_grpc` stubs need: +// they refuse to import under a grpcio older than the grpc_tools that +// generated them (GRPC_GENERATED_VERSION), which the proto companion pins. +const pythonGrpcRequirement = "grpcio>=1.71.0" + // pyprojectDependenciesRe matches pyproject.toml's single-line project // dependencies array, the shape writePythonScaffold writes. var pyprojectDependenciesRe = regexp.MustCompile(`(?m)^dependencies = \[([^\]]*)\]$`) @@ -234,9 +319,13 @@ func ensurePythonDependencies(langDir string, packages []string) error { return fmt.Errorf("cannot find a dependencies array in %s", path) } existing := string(data[match[2]:match[3]]) + declared := map[string]bool{} + for _, requirement := range strings.Split(existing, ",") { + declared[pythonDistribution(strings.Trim(strings.TrimSpace(requirement), `"`))] = true + } var missing []string for _, pkg := range packages { - if !strings.Contains(existing, `"`+pkg+`"`) { + if !declared[pythonDistribution(pkg)] { missing = append(missing, `"`+pkg+`"`) } } @@ -254,6 +343,17 @@ func ensurePythonDependencies(langDir string, packages []string) error { return os.WriteFile(path, updated, 0o644) //nolint:gosec } +// pythonDistribution is the distribution a requirement names, without its +// version specifier, so a developer's own pin of a package counts as declaring +// it. +func pythonDistribution(requirement string) string { + name, _, _ := strings.Cut(requirement, ";") + if i := strings.IndexAny(name, "<>=!~[ "); i >= 0 { + name = name[:i] + } + return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(name), "_", "-")) +} + // assembleOpenAPILibrary synthesizes a basev0.Endpoint carrying the raw // OpenAPI document (the only thing proto.GenerateOpenAPI reads) and runs it. // OpenAPI clients have no facade: proto.GenerateOpenAPI's contract-derived @@ -694,7 +794,7 @@ func writePythonScaffold(langDir, pyPackage string) error { pyproject := fmt.Sprintf(`[project] name = %q version = "0.0.1" -dependencies = ["protobuf>=5.29.3,<7"] +dependencies = ["protobuf>=5.29.3,<7", %q] [build-system] requires = ["setuptools>=68"] @@ -702,6 +802,6 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = [%q, %q] -`, strings.ReplaceAll(pyPackage, "_", "-"), pyPackage, pyPackage+".*") +`, strings.ReplaceAll(pyPackage, "_", "-"), pythonGrpcRequirement, pyPackage, pyPackage+".*") return writeFileIfMissing(filepath.Join(langDir, "pyproject.toml"), []byte(pyproject)) } diff --git a/pkg/generators/client_library_test.go b/pkg/generators/client_library_test.go index 19fa614a6..c3d9fb85a 100644 --- a/pkg/generators/client_library_test.go +++ b/pkg/generators/client_library_test.go @@ -143,6 +143,66 @@ addopts = "-q" } } +// TestEnsurePythonDependenciesKeepsADevelopersOwnPin pins that a package is +// declared by its distribution name, not by the exact requirement string: a +// developer who pinned grpcio or protovalidate themselves keeps that pin, and +// the library never gains a second, conflicting requirement for it. +func TestEnsurePythonDependenciesKeepsADevelopersOwnPin(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "pyproject.toml") + original := `[project] +dependencies = ["protobuf>=5.29.3,<7", "grpcio==1.75.1", "Protovalidate>=0.7"] +` + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + if err := ensurePythonDependencies(dir, []string{pythonGrpcRequirement, "protovalidate", "googleapis-common-protos"}); err != nil { + t.Fatalf("ensurePythonDependencies: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := `dependencies = ["protobuf>=5.29.3,<7", "grpcio==1.75.1", "Protovalidate>=0.7", "googleapis-common-protos"]` + if !strings.Contains(string(got), want) { + t.Fatalf("got:\n%s\nwant the line:\n%s", got, want) + } +} + +func TestPythonDistribution(t *testing.T) { + for requirement, want := range map[string]string{ + "grpcio>=1.71.0": "grpcio", + "protobuf>=5.29.3,<7": "protobuf", + "googleapis-common-protos": "googleapis-common-protos", + "Googleapis_Common_Protos==1.66": "googleapis-common-protos", + "protovalidate ~= 0.7": "protovalidate", + "grpcio[protobuf]>=1.71": "grpcio", + `grpcio; python_version >= "3.10"`: "grpcio", + "opentelemetry-proto!=1.2": "opentelemetry-proto", + } { + if got := pythonDistribution(requirement); got != want { + t.Errorf("pythonDistribution(%q) = %q, want %q", requirement, got, want) + } + } +} + +// TestPythonScaffoldDeclaresGrpcio pins that a new library depends on the +// grpcio its *_pb2_grpc stubs check at import, the floor the companion's +// grpc_tools sets. +func TestPythonScaffoldDeclaresGrpcio(t *testing.T) { + dir := t.TempDir() + if err := writePythonScaffold(dir, "authority_client"); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(dir, "pyproject.toml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), `dependencies = ["protobuf>=5.29.3,<7", "grpcio>=1.71.0"]`) { + t.Fatalf("scaffold does not declare grpcio:\n%s", got) + } +} + // TestTypeScriptLibraryDeclaresAnEntryPointItProduces pins the two halves of // the generated package that must agree: package.json's "exports" and the file // tree. They lived in different functions and disagreed — the scaffold named diff --git a/pkg/generators/client_test.go b/pkg/generators/client_test.go index 062be5f22..74088bf86 100644 --- a/pkg/generators/client_test.go +++ b/pkg/generators/client_test.go @@ -3,8 +3,11 @@ package generators import ( "bytes" "slices" + "strings" "testing" + hopv1 "github.com/codefly-dev/authority/libraries/hop/go/gen/authority/hop/v1" + coreproto "github.com/codefly-dev/core/companions/proto" "github.com/codefly-dev/core/languages" "google.golang.org/protobuf/encoding/protowire" googleproto "google.golang.org/protobuf/proto" @@ -191,7 +194,7 @@ func TestMarkUpstreamModulesAsImportsReportsPythonPackages(t *testing.T) { _, marked := markSet(t, set, languages.PYTHON) got := pythonPackagesFor(marked) - want := []string{"googleapis-common-protos", "protovalidate"} + want := []string{"googleapis-common-protos"} if !slices.Equal(got, want) { t.Fatalf("pythonPackagesFor = %v, want %v", got, want) } @@ -209,6 +212,38 @@ func TestMarkUpstreamModulesAsImportsReportsPythonPackages(t *testing.T) { } } +// TestUpstreamModulesInReportsWhatMarkingWould pins the sources path against +// the descriptor-set path: a library generated from proto sources leaves the +// same shared modules to their distributions, so it must declare the same +// packages, and its own first-party files declare none. +func TestUpstreamModulesInReportsWhatMarkingWould(t *testing.T) { + set := &descriptorpb.FileDescriptorSet{ + File: []*descriptorpb.FileDescriptorProto{ + {Name: googleproto.String("google/protobuf/timestamp.proto"), Package: googleproto.String("google.protobuf")}, + {Name: googleproto.String("google/api/annotations.proto"), Package: googleproto.String("google.api")}, + {Name: googleproto.String("google/api/http.proto"), Package: googleproto.String("google.api")}, + {Name: googleproto.String("buf/validate/validate.proto"), Package: googleproto.String("buf.validate")}, + {Name: googleproto.String("authority/v1/authority.proto"), Package: googleproto.String("authority.v1")}, + }, + } + + _, marked := markSet(t, set, languages.PYTHON) + fromSources := upstreamModulesIn(set, languages.PYTHON) + if got, want := pythonPackagesFor(fromSources), pythonPackagesFor(marked); !slices.Equal(got, want) { + t.Fatalf("sources path declares %v, descriptor-set path %v", got, want) + } + if got := pythonPackagesFor(fromSources); !slices.Equal(got, []string{"googleapis-common-protos"}) { + t.Fatalf("pythonPackagesFor(upstreamModulesIn) = %v", got) + } + + ownOnly := &descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{ + {Name: googleproto.String("authority/v1/authority.proto"), Package: googleproto.String("authority.v1")}, + }} + if got := upstreamModulesIn(ownOnly, languages.PYTHON); len(got) != 0 { + t.Fatalf("upstreamModulesIn(first-party only) = %v, want none", got) + } +} + // TestMarkUpstreamModulesAsImportsWireFormat pins the exact bytes buf reads // the markers from. Every other assertion in this file is written against the // same field-number constants the markers are built from, so it would keep @@ -378,3 +413,49 @@ func bufModuleIdentity(t *testing.T, fileName string, moduleInfo []byte) string } return segments[bufModuleNameRemoteField] + "/" + segments[bufModuleNameOwnerField] + "/" + segments[bufModuleNameRepositoryField] } + +// A service that vendors an upstream module's protos under its own tree does +// not have them generated as its own files. +func TestVendoredUpstreamProtosAreNotOwnSources(t *testing.T) { + set := &descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{ + {Name: googleproto.String("documents/v1/api.proto"), Package: googleproto.String("documents.v1")}, + {Name: googleproto.String("codefly/base/v0/agent.proto"), Package: googleproto.String("codefly.base.v0")}, + }} + own := []coreproto.Source{{Path: "documents/v1/api.proto"}} + if vendorsUpstreamModules(own, set, languages.GO) { + t.Fatal("a service's own file is not an upstream module") + } + vendored := append(own, coreproto.Source{Path: "codefly/base/v0/agent.proto"}) + if !vendorsUpstreamModules(vendored, set, languages.GO) { + t.Fatal("a vendored codefly/base file is an upstream module's") + } +} + +// The hop's policy option is foreign to a Go library by its go_package, the +// package whose bindings the CLI links: vendored under a module's tree it is +// the hop's file, while a module's own file under the same path is its own. +func TestTheVendoredHopOptionIsForeignByItsGoPackage(t *testing.T) { + policy := hopv1.File_authority_hop_v1_policy_proto + goPackage := policy.Options().(*descriptorpb.FileOptions).GetGoPackage() + set := &descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{ + {Name: googleproto.String("documents/v1/api.proto"), Package: googleproto.String("documents.v1")}, + {Name: googleproto.String(policy.Path()), Package: googleproto.String(string(policy.Package())), + Options: &descriptorpb.FileOptions{GoPackage: googleproto.String(goPackage)}}, + {Name: googleproto.String("authority/hop/v1/notes.proto"), Package: googleproto.String("authority.hop.v1"), + Options: &descriptorpb.FileOptions{GoPackage: googleproto.String("github.com/acme/documents/pkg/gen/authority/hop/v1;hopv1")}}, + }} + own := []coreproto.Source{{Path: "documents/v1/api.proto"}, {Path: "authority/hop/v1/notes.proto"}} + if vendorsUpstreamModules(own, set, languages.GO) { + t.Fatal("a module's own file under the hop's path is its own") + } + if !vendorsUpstreamModules(append(own, coreproto.Source{Path: policy.Path()}), set, languages.GO) { + t.Fatal("the vendored hop option is the hop module's, imported by its go_package") + } + if vendorsUpstreamModules(append(own, coreproto.Source{Path: policy.Path()}), set, languages.PYTHON) { + t.Fatal("no Python distribution publishes the hop option, so a Python library owns it") + } + importPath, _, _ := strings.Cut(goPackage, ";") + if got := linkedGoBindings(); importPath == "" || !slices.Equal(got, []string{importPath}) { + t.Fatalf("linked Go bindings %v do not name the hop's go_package %q", got, goPackage) + } +} diff --git a/pkg/generators/python_ownership_test.go b/pkg/generators/python_ownership_test.go new file mode 100644 index 000000000..5641682d5 --- /dev/null +++ b/pkg/generators/python_ownership_test.go @@ -0,0 +1,131 @@ +package generators + +import ( + "os" + "path/filepath" + "testing" +) + +// pythonFacade is a facade as the Python facade plugin writes it: its module +// docstring carries the plugin's generated-code notice. +func pythonFacade(module string) string { + return `"""Gateway-bound facade for the ` + "`" + module + "`" + ` module — generated, do not edit. + +generated by protoc-gen-codefly-facade-python from pdf.v1; do not edit +Gateway seams: unary. +""" + +from __future__ import annotations + +import pdf.v1.api_pb2 as pdf_v1_api_pb2 + + +def ` + module + `(gateway): + return gateway +` +} + +// regeneratePython runs one Python library generation over pkgDir the way +// generateProtobufLanguageEntries does — wipe once, merge the entry, finalize +// — from a raw plugin output holding the facade and one binding. +func regeneratePython(t *testing.T, langDir, module string) { + t.Helper() + raw := t.TempDir() + for path, content := range map[string]string{ + filepath.Join(raw, module+".py"): pythonFacade(module), + filepath.Join(raw, "pdf", "v1", "api_pb2.py"): "# Generated by the protocol buffer compiler. DO NOT EDIT!\n", + } { + writeTestFile(t, path, content) + } + pkgDir := filepath.Join(langDir, "acme_pdf") + if err := wipePythonLibrary(pkgDir); err != nil { + t.Fatalf("wipePythonLibrary: %v", err) + } + if err := mergePythonEntry(pkgDir, raw, module, "acme_pdf"); err != nil { + t.Fatalf("mergePythonEntry: %v", err) + } + if _, err := finalizePythonLibrary(langDir, "acme_pdf", nil); err != nil { + t.Fatalf("finalizePythonLibrary: %v", err) + } +} + +func writeTestFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// TestRegeneratingAPythonLibraryKeepsWhatItDidNotWrite: the package root is +// not a purely generated directory. A regeneration removes and rewrites what +// a protoc plugin's notice says it wrote — the facade, including a stale one +// left by an earlier --module-name — and its _gen/ tree, and keeps every +// other file: a handwritten reader beside the facade, a helper quoting the +// facade's notice in its body, and the package's own __init__.py. +func TestRegeneratingAPythonLibraryKeepsWhatItDidNotWrite(t *testing.T) { + langDir := t.TempDir() + pkgDir := filepath.Join(langDir, "acme_pdf") + const reader = "\"\"\"Reads a pdf through the facade.\"\"\"\n\nfrom acme_pdf.pdf import pdf\n\n\ndef read(gateway):\n return pdf(gateway)\n" + const quoting = "import textwrap\n\nHEADER = textwrap.dedent('''\n generated by protoc-gen-codefly-facade-python from pdf.v1; do not edit\n''')\n" + const initFile = "from acme_pdf.reader import read\n" + handwritten := map[string]string{ + filepath.Join(pkgDir, "reader.py"): reader, + filepath.Join(pkgDir, "quoting.py"): quoting, + filepath.Join(pkgDir, "__init__.py"): initFile, + filepath.Join(pkgDir, "notes", "a.py"): "# a subpackage of the developer's\n", + } + for path, content := range handwritten { + writeTestFile(t, path, content) + } + // What an earlier generation left: a facade under an older module name, + // a file another protoc plugin's standard notice claims, and a binding. + writeTestFile(t, filepath.Join(pkgDir, "old.py"), pythonFacade("old")) + writeTestFile(t, filepath.Join(pkgDir, "api_grpc.py"), "# Code generated by protoc-gen-codefly-python. DO NOT EDIT.\n") + writeTestFile(t, filepath.Join(pkgDir, "_gen", "stale", "v1", "api_pb2.py"), "") + + for run := 1; run <= 2; run++ { + regeneratePython(t, langDir, "pdf") + for path, content := range handwritten { + got, err := os.ReadFile(path) + if err != nil || string(got) != content { + t.Fatalf("run %d: %s was written by hand and is kept byte for byte, got %q (%v)", run, path, got, err) + } + } + for _, gone := range []string{"old.py", "api_grpc.py", filepath.Join("_gen", "stale")} { + if _, err := os.Stat(filepath.Join(pkgDir, gone)); !os.IsNotExist(err) { + t.Fatalf("run %d: %s was generated and is removed (stat: %v)", run, gone, err) + } + } + for _, made := range []string{"pdf.py", filepath.Join("_gen", "pdf", "v1", "api_pb2.py"), filepath.Join("_gen", "__init__.py")} { + if _, err := os.Stat(filepath.Join(pkgDir, made)); err != nil { + t.Fatalf("run %d: %s is generated: %v", run, made, err) + } + } + } +} + +// TestAPythonNoticeIsReadFromTheLeadingBlockOnly: the generator a file names +// is read from the comments and the docstring it opens with, in either +// spelling, and from nothing after its first statement. +func TestAPythonNoticeIsReadFromTheLeadingBlockOnly(t *testing.T) { + dir := t.TempDir() + for content, want := range map[string]string{ + pythonFacade("pdf"): "protoc-gen-codefly-facade-python", + "#!/usr/bin/env python\n# Code generated by protoc-gen-x. DO NOT EDIT.\n": "protoc-gen-x", + "# Code generated by codefly generate permissions. DO NOT EDIT.\n": "codefly generate permissions", + "\"\"\"generated by protoc-gen-y from a.v1; do not edit\"\"\"\n": "protoc-gen-y", + "\"\"\"A docstring.\"\"\"\n# Code generated by protoc-gen-z. DO NOT EDIT.\n": "", + "x = 1\n# Code generated by protoc-gen-z. DO NOT EDIT.\n": "", + "": "", + } { + path := filepath.Join(dir, "f.py") + writeTestFile(t, path, content) + got, err := pythonNoticeGenerator(path) + if err != nil || got != want { + t.Errorf("pythonNoticeGenerator(%q) = %q (%v), want %q", content, got, err, want) + } + } +} diff --git a/pkg/generators/tree.go b/pkg/generators/tree.go new file mode 100644 index 000000000..26c7472a0 --- /dev/null +++ b/pkg/generators/tree.go @@ -0,0 +1,76 @@ +package generators + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "sort" +) + +// DiffTrees returns the slash-separated, sorted set of relative paths whose +// content (or presence) differs between the two directory trees. +func DiffTrees(generated, existing string) ([]string, error) { + generatedFiles, err := ReadTree(generated) + if err != nil { + return nil, err + } + existingFiles, err := ReadTree(existing) + if err != nil { + return nil, err + } + + seen := map[string]struct{}{} + for f := range generatedFiles { + seen[f] = struct{}{} + } + for f := range existingFiles { + seen[f] = struct{}{} + } + + var diffs []string + for f := range seen { + a, okA := generatedFiles[f] + b, okB := existingFiles[f] + if okA != okB || !bytes.Equal(a, b) { + diffs = append(diffs, f) + } + } + sort.Strings(diffs) + return diffs, nil +} + +// ReadTree reads a directory into relative slash-separated file names and bytes. +// A missing tree is empty, allowing generation checks to report absent outputs. +func ReadTree(root string) (map[string][]byte, error) { + files := map[string][]byte{} + info, err := os.Stat(root) + if err != nil { + if os.IsNotExist(err) { + return files, nil + } + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("%s is not a directory", root) + } + err = filepath.WalkDir(root, func(p string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + rel, relErr := filepath.Rel(root, p) + if relErr != nil { + return relErr + } + data, readErr := os.ReadFile(p) //nolint:gosec // p is walked from root, a generated-contracts directory this command owns + if readErr != nil { + return readErr + } + files[filepath.ToSlash(rel)] = data + return nil + }) + return files, err +} diff --git a/pkg/gitops/audience_list_binding_test.go b/pkg/gitops/audience_list_binding_test.go new file mode 100644 index 000000000..b294351c9 --- /dev/null +++ b/pkg/gitops/audience_list_binding_test.go @@ -0,0 +1,205 @@ +package gitops + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + modulecontract "github.com/codefly-dev/core/contracts/module" + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/solutionhost" + "github.com/codefly-dev/core/wool" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + + hopv1 "github.com/codefly-dev/authority/libraries/hop/go/gen/authority/hop/v1" +) + +// audienceListContract is the shop's contract with its mandatory model binding +// and two optional bindings addressed to a LIST of owners the composition +// writes: owner names the owner protocol's calls, owner-review a bare ceiling +// qualified per owner. +const audienceListContract = `schema: codefly/module-contract/v1 +principal: shop +namespaces: [shop] +queues: [] +scope_ceilings: [] +bindings: + - id: model + operations: [invoke] + audience: {from: assistant/model-audience} + resource_kind: {from: assistant/model-resource-kind} + scope_ceiling: + invoke: [invoke] + - id: owner + optional: true + operations: [invoke, headless] + audiences: {from: assistant/owner-audiences} + calls: + invoke: + - /targets.v1.TargetService/Reach + headless: + - /targets.v1.TargetService/ListKinds + - id: owner-review + optional: true + operations: [invoke] + audiences: {from: assistant/owner-audiences} + resource_kinds: {from: assistant/owner-resource-kinds} + scope_ceiling: + invoke: [read, review] +destinations: [] +` + +const bothOwners = "OWNER_AUDIENCES=robin,documents\nOWNER_RESOURCE_KINDS=robin=robin.answers,documents=documents.versions\n" + +// targetPolicy is one owner's declaration for one owner-protocol method: +// what it requires. +type targetPolicy struct{ requires []*hopv1.Requirement } + +// writeOwner composes one owner module publishing the owner protocol +// (targets.v1.TargetService) with its own policies, in its own vocabulary. +func writeOwner(t *testing.T, root, name string, policies map[string]targetPolicy) { + t.Helper() + service := &descriptorpb.ServiceDescriptorProto{Name: proto.String("TargetService")} + for _, method := range []string{"Reach", "ListKinds"} { + policy, served := policies[method] + if !served { + continue + } + m := &descriptorpb.MethodDescriptorProto{Name: proto.String(method), InputType: proto.String(".targets.v1.Empty"), OutputType: proto.String(".targets.v1.Empty"), Options: &descriptorpb.MethodOptions{}} + proto.SetExtension(m.Options, hopv1.E_Policy, &hopv1.Policy{Requires: policy.requires}) + service.Method = append(service.Method, m) + } + set, err := proto.Marshal(&descriptorpb.FileDescriptorSet{File: []*descriptorpb.FileDescriptorProto{{ + Name: proto.String("targets/v1/api.proto"), Package: proto.String("targets.v1"), Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{{Name: proto.String("Empty")}}, Service: []*descriptorpb.ServiceDescriptorProto{service}, + }}}) + require.NoError(t, err) + writeCallee(t, filepath.Join(root, "modules", name), name, "grpc", "protobuf", "targets.v1", "contract.binpb", set) +} + +func requirement(kind string, actions ...string) []*hopv1.Requirement { + return []*hopv1.Requirement{{ResourceKind: kind, Actions: actions}} +} + +// audienceListWorkspace composes the shop beside robin and the document store, +// each serving the owner protocol under its own policies; documentsListKinds +// says whether the document store serves ListKinds. +func audienceListWorkspace(t *testing.T, documentsListKinds bool, assistant string) (*resources.Workspace, *resources.Module) { + t.Helper() + ctx := context.Background() + workspace, module := writeAuthorityWorkspace(t) + configuration := filepath.Join(workspace.Dir(), resources.WorkspaceConfigurationName) + config, err := os.ReadFile(configuration) + require.NoError(t, err) + require.Contains(t, string(config), "modules:\n - name: shop\n") + require.NoError(t, os.WriteFile(configuration, []byte(strings.Replace(string(config), "modules:\n - name: shop\n", "modules:\n - name: shop\n - name: robin\n - name: documents\n", 1)), 0o644)) + writeOwner(t, workspace.Dir(), "robin", map[string]targetPolicy{ + "Reach": {requires: requirement("robin.answers", "read")}, + "ListKinds": {requires: requirement("robin.answers", "list")}, + }) + documents := map[string]targetPolicy{"Reach": {requires: append(requirement("documents.versions", "read"), requirement("documents", "read")...)}} + if documentsListKinds { + documents["ListKinds"] = targetPolicy{requires: requirement("documents.versions", "list")} + } + writeOwner(t, workspace.Dir(), "documents", documents) + writeShopSlots(t, workspace.Dir(), module.Dir(), audienceListContract, assistant) + workspace, err = resources.LoadWorkspaceFromDir(ctx, workspace.Dir()) + require.NoError(t, err) + module, err = workspace.LoadModuleFromName(ctx, "shop") + require.NoError(t, err) + return workspace, module +} + +// TestABindingAddressedToTwoOwnersIsDeliveredOncePerOwner: each listed owner +// gets its own binding, :, with the ceiling read from THAT +// owner's own policies for the calls named, and the bare ceiling qualified by +// that owner's kind; the delivered manifest carries the same, and the render +// says which ids each declared binding became. +func TestABindingAddressedToTwoOwnersIsDeliveredOncePerOwner(t *testing.T) { + workspace, module := audienceListWorkspace(t, true, bothOwners) + env := selectedEnvironment(t, workspace, "staging") + recorder := &infoRecorder{} + ctx := wool.New(context.Background(), &wool.Resource{Kind: "test", Unique: "test"}).WithLogger(recorder).Inject(context.Background()) + request, err := authorityRequestOf(module) + require.NoError(t, err) + authority, err := moduleAuthority(ctx, workspace, module, loadServices(t, workspace, "shop", "api"), env, request) + require.NoError(t, err) + want := []solutionhost.AuthorityBinding{ + modelOnly, + {ID: "owner:documents", Audience: "documents", Operations: map[string][]string{ + "invoke": {"documents.versions:read", "documents:read"}, "headless": {"documents.versions:list"}, + }}, + {ID: "owner:robin", Audience: "robin", Operations: map[string][]string{ + "invoke": {"robin.answers:read"}, "headless": {"robin.answers:list"}, + }}, + {ID: "owner-review:documents", Audience: "documents", Operations: map[string][]string{ + "invoke": {"documents.versions:read", "documents.versions:review"}, + }}, + {ID: "owner-review:robin", Audience: "robin", Operations: map[string][]string{ + "invoke": {"robin.answers:read", "robin.answers:review"}, + }}, + } + require.Equal(t, want, authority.Bindings) + _, manifest := renderShopManifest(t, workspace, module, env) + require.Equal(t, want, manifest.Authority.Bindings, "the delivered manifest carries one binding per listed owner") + logged := recorder.joined() + require.Contains(t, logged, "module shop: binding owner is delivered once per listed audience, as owner:documents, owner:robin") + require.Contains(t, logged, "module shop: binding owner-review is delivered once per listed audience, as owner-review:documents, owner-review:robin") +} + +// TestAnOwnerCallOneListedOwnerDoesNotServeIsRefusedByName: the document store +// does not serve ListKinds, so the render refuses naming the binding, the call +// and that owner, and robin, which serves it, is not blamed. +func TestAnOwnerCallOneListedOwnerDoesNotServeIsRefusedByName(t *testing.T) { + workspace, module := audienceListWorkspace(t, false, bothOwners) + env := selectedEnvironment(t, workspace, "staging") + _, err := shopAuthority(t, workspace, module, loadServices(t, workspace, "shop", "api"), env) + require.ErrorIs(t, err, modulecontract.ErrUnresolvedCall) + require.Contains(t, err.Error(), "binding owner headless call /targets.v1.TargetService/ListKinds at documents: documents does not serve it") + require.NotContains(t, err.Error(), "at robin") +} + +// TestAnOptionalOwnerListLeftEmptyIsOmitted: no owner listed — absent, or +// supplied empty — renders the shop without either binding, delivered +// manifest included, each omission logged. +func TestAnOptionalOwnerListLeftEmptyIsOmitted(t *testing.T) { + for name, assistant := range map[string]string{ + "absent": "", + "supplied empty": "OWNER_AUDIENCES=\nOWNER_RESOURCE_KINDS=\n", + } { + t.Run(name, func(t *testing.T) { + workspace, module := audienceListWorkspace(t, true, assistant) + env := selectedEnvironment(t, workspace, "staging") + recorder := &infoRecorder{} + ctx := wool.New(context.Background(), &wool.Resource{Kind: "test", Unique: "test"}).WithLogger(recorder).Inject(context.Background()) + request, err := authorityRequestOf(module) + require.NoError(t, err) + authority, err := moduleAuthority(ctx, workspace, module, loadServices(t, workspace, "shop", "api"), env, request) + require.NoError(t, err) + require.Equal(t, []solutionhost.AuthorityBinding{modelOnly}, authority.Bindings) + logged := recorder.joined() + require.Contains(t, logged, "module shop: optional binding owner is omitted from the delivered manifest") + require.Contains(t, logged, "module shop: optional binding owner-review is omitted from the delivered manifest") + require.NotContains(t, logged, "delivered once per listed audience") + }) + } +} + +// TestASingleOwnerListDeliversOneBinding: a list of one is still delivered +// under the derived id, so a composition adding a second owner later adds a +// binding and renames none. +func TestASingleOwnerListDeliversOneBinding(t *testing.T) { + workspace, module := audienceListWorkspace(t, true, "OWNER_AUDIENCES=robin\nOWNER_RESOURCE_KINDS=robin=robin.answers\n") + env := selectedEnvironment(t, workspace, "staging") + authority, err := shopAuthority(t, workspace, module, loadServices(t, workspace, "shop", "api"), env) + require.NoError(t, err) + ids := make([]string, 0, len(authority.Bindings)) + for _, binding := range authority.Bindings { + ids = append(ids, binding.ID) + } + require.Equal(t, []string{"model", "owner:robin", "owner-review:robin"}, ids) +} diff --git a/pkg/gitops/authority.go b/pkg/gitops/authority.go index d8ef6094e..060498573 100644 --- a/pkg/gitops/authority.go +++ b/pkg/gitops/authority.go @@ -9,12 +9,15 @@ import ( "slices" "strings" + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" + "github.com/codefly-dev/cli/pkg/environments" "github.com/codefly-dev/core/configurations" modulecontract "github.com/codefly-dev/core/contracts/module" "github.com/codefly-dev/core/resources" solutionmanifest "github.com/codefly-dev/core/solution/manifest" "github.com/codefly-dev/core/solutionhost" + "github.com/codefly-dev/core/wool" ) // --- What a module asks for --- @@ -72,28 +75,69 @@ func moduleAuthority( request authorityRequest, ) (*solutionhost.Authority, error) { authority := &solutionhost.Authority{Principal: module.Name} + if err := refuseRetiredRoles(module.Dir()); err != nil { + return nil, fmt.Errorf("module %s: %w", module.Name, err) + } if request.undeclared != "" { return authority, nil } + // The principal is the module's own name, and the contract is held to it + // before anything resolves: a module claiming another module's principal + // would claim its grant, and a binding addressed to the module itself + // ({self: true}) resolves to the contract's principal — this name. + if request.contract.Principal != module.Name { + return nil, fmt.Errorf("module %s publishes a contract whose principal is %q; the principal every credential of a module is issued to is the module's own name, so the contract declares principal: %s", module.Name, request.contract.Principal, module.Name) + } provided, err := configurations.ReadWorkspaceConfigurations(ctx, workspace, env.Runtime()) if err != nil { return nil, fmt.Errorf("read the workspace configurations the contract of %s resolves against: %w", module.Name, err) } - resolved, err := request.contract.Resolve(workspaceValues{provided: provided}) + callees := newComposedCallees(func(name string) ([]*compositionv1.MethodPolicyV1, bool, error) { + callee, loadErr := workspace.LoadModuleFromName(ctx, name) + if loadErr != nil { + return nil, false, fmt.Errorf("it is not a module this workspace composes: %w", loadErr) + } + return MethodPolicies(callee.Dir()) + }) + resolved, err := request.contract.Resolve(workspaceValues{provided: provided}, modulecontract.WithCallees(callees)) if err != nil { return nil, fmt.Errorf("module %s: %w", module.Name, err) } if err := refuseUnaddressableConsumption(workspace, module, request, resolved); err != nil { return nil, err } - // The principal is the module's own name, and the contract is held to it: - // a module claiming another module's principal would claim its grant. - if resolved.Principal != module.Name { - return nil, fmt.Errorf("module %s publishes a contract whose principal is %q; the principal every credential of a module is issued to is the module's own name, so the contract declares principal: %s", module.Name, resolved.Principal, module.Name) - } if err := refuseUndeclaredDestinations(module.Name, resolved, services); err != nil { return nil, err } + // An optional binding the composition fills none of the slots of is left + // out of the delivered manifest by core's resolution, so no credential can + // be issued through it; the render says so rather than drop it silently. + for _, omitted := range resolved.Omitted { + wool.Get(ctx).In("gitops.moduleAuthority").Info( + fmt.Sprintf("module %s: optional binding %s is omitted from the delivered manifest: %s", module.Name, omitted.ID, omitted.Reason), + wool.Field("module", module.Name), wool.Field("binding", omitted.ID)) + } + // A binding addressed to a list of audiences is delivered once per listed + // audience, each its own binding (core's AudienceBindingID) with the + // ceiling that audience's own declarations give it; the render says which, + // since the delivered ids are not the ones the contract spells. + for i := range request.contract.Bindings { + declared := &request.contract.Bindings[i] + if declared.Audiences == nil { + continue + } + var delivered []string + for _, binding := range resolved.Bindings { + if strings.HasPrefix(binding.ID, modulecontract.AudienceBindingID(declared.ID, "")) { + delivered = append(delivered, binding.ID) + } + } + if len(delivered) > 0 { + wool.Get(ctx).In("gitops.moduleAuthority").Info( + fmt.Sprintf("module %s: binding %s is delivered once per listed audience, as %s", module.Name, declared.ID, strings.Join(delivered, ", ")), + wool.Field("module", module.Name), wool.Field("binding", declared.ID)) + } + } for _, binding := range resolved.Bindings { operations := make(map[string][]string, len(binding.Operations)) for _, operation := range binding.Operations { @@ -264,3 +308,54 @@ func refuseUndeclaredDestinations(module string, contract *modulecontract.Resolv } return fmt.Errorf("%w: module %s declares the destinations %s, and none of its services declares such an endpoint; declare the endpoint on the service or withdraw the destination", errUnrenderedDestination, module, strings.Join(missing, ", ")) } + +// composedCallees is modulecontract.Callees over the composed modules: what a +// call to one requires is that module's own method policy for it, read from +// its published contracts, so a binding that names its calls carries exactly +// what the callee declares and the caller names none of its kinds. A callee's +// policies are read once per resolution. +type composedCallees struct { + read func(module string) ([]*compositionv1.MethodPolicyV1, bool, error) + policies map[string]map[string]*compositionv1.MethodPolicyV1 +} + +func newComposedCallees(read func(module string) ([]*compositionv1.MethodPolicyV1, bool, error)) *composedCallees { + return &composedCallees{read: read, policies: map[string]map[string]*compositionv1.MethodPolicyV1{}} +} + +// Requires implements modulecontract.Callees. A call the audience does not +// serve, or one it serves declaring nothing (which the hop refuses), is an +// error naming it; a call open to any checked caller requires no scope. +func (c *composedCallees) Requires(audience, call string) ([]string, error) { + methods, held := c.policies[audience] + if !held { + served, declared, err := c.read(audience) + if err != nil { + return nil, err + } + if !declared { + return nil, fmt.Errorf("%s publishes no contracts, so what its calls require cannot be read", audience) + } + methods = make(map[string]*compositionv1.MethodPolicyV1, len(served)) + for _, method := range served { + methods[method.GetMethod()] = method + } + c.policies[audience] = methods + } + method, served := methods[call] + switch { + case !served: + return nil, fmt.Errorf("%s does not serve it", audience) + case method.GetAnyCheckedCaller(): + return nil, nil + case len(method.GetRequires()) == 0: + return nil, fmt.Errorf("%s declares nothing for it, and the hop refuses a call to a method that declares nothing", audience) + } + var scopes []string + for _, required := range method.GetRequires() { + for _, action := range required.GetActions() { + scopes = append(scopes, required.GetResourceKind()+":"+action) + } + } + return scopes, nil +} diff --git a/pkg/gitops/composed_callees_test.go b/pkg/gitops/composed_callees_test.go new file mode 100644 index 000000000..0ca43a011 --- /dev/null +++ b/pkg/gitops/composed_callees_test.go @@ -0,0 +1,61 @@ +package gitops + +import ( + "errors" + "strings" + "testing" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" +) + +// The store's own declarations, as its published contracts carry them. +func storePolicies() []*compositionv1.MethodPolicyV1 { + read := []*authorityv1.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}}, {ResourceKind: "documents.collection", Actions: []string{"read"}}} + return []*compositionv1.MethodPolicyV1{ + {Method: "/documents.v1.DocumentsService/ResolveEvidence", Requires: read}, + {Method: "/documents.v1.DocumentsService/Version", AnyCheckedCaller: true}, + {Method: "/documents.v1.DocumentsService/Unmarked"}, + } +} + +// What a call requires is the callee's own method policy: every scope it names, +// nothing for a call open to any checked caller; an unserved call, an unmarked +// one and an uncomposed callee are each refused by name. A callee is read once. +func TestACallRequiresWhatTheComposedCalleeDeclares(t *testing.T) { + reads := 0 + callees := newComposedCallees(func(module string) ([]*compositionv1.MethodPolicyV1, bool, error) { + reads++ + switch module { + case "documents": + return storePolicies(), true, nil + case "bare": + return nil, false, nil + } + return nil, false, errors.New("it is not a module this workspace composes") + }) + got, err := callees.Requires("documents", "/documents.v1.DocumentsService/ResolveEvidence") + if err != nil || strings.Join(got, ",") != "documents:read,documents.collection:read" { + t.Fatalf("ResolveEvidence: %v %v", got, err) + } + if got, err := callees.Requires("documents", "/documents.v1.DocumentsService/Version"); err != nil || len(got) != 0 { + t.Fatalf("an open call requires nothing: %v %v", got, err) + } + for call, want := range map[string]string{ + "/documents.v1.DocumentsService/Missing": "does not serve it", + "/documents.v1.DocumentsService/Unmarked": "declares nothing for it", + } { + if _, err := callees.Requires("documents", call); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v", call, err) + } + } + if reads != 1 { + t.Fatalf("the callee was read %d times; once per resolution", reads) + } + if _, err := callees.Requires("bare", "/x.v1.S/M"); err == nil || !strings.Contains(err.Error(), "publishes no contracts") { + t.Fatalf("a callee with no contracts: %v", err) + } + if _, err := callees.Requires("elsewhere", "/x.v1.S/M"); err == nil || !strings.Contains(err.Error(), "not a module this workspace composes") { + t.Fatalf("an uncomposed callee: %v", err) + } +} diff --git a/pkg/gitops/composition_delivery.go b/pkg/gitops/composition_delivery.go new file mode 100644 index 000000000..3616d2590 --- /dev/null +++ b/pkg/gitops/composition_delivery.go @@ -0,0 +1,207 @@ +package gitops + +import ( + "context" + "fmt" + "net/http" + "sync" + + "github.com/codefly-dev/core/solutionhost" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + + delivery "github.com/codefly-dev/authority/libraries/delivery/go" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" +) + +// CompositionDelivery delivers through codefly.dev/composition: the signed +// carrier and the module's delivery credential go to ApplyManifest, which +// answers synchronously with the generation the host holds. The run then knows +// nothing of the host but the interface it declared. +type CompositionDelivery struct { + client compositionv1.CompositionServiceClient + // signer is the run's release key, which signs the core manifest too. + signer solutionhost.Signer + // methods reads what each method of a module declares, from its package; + // ok false means the module declares no method policy. + methods func(ctx context.Context, module string) ([]*compositionv1.MethodPolicyV1, bool, error) + // ceilings reads the module's own resource kinds with their actions, from + // its module contract; none when it publishes no contract. + ceilings func(ctx context.Context, module string) ([]*authorityv1.WorkScopeV1, error) + // roles reads the roles a solution declares, from its module contract; + // none for a module, which declares no roles. + roles func(ctx context.Context, module string) ([]*compositionv1.RoleV1, error) + + mu sync.Mutex + applied map[string]AppliedManifest +} + +// NewCompositionDelivery speaks the interface over conn. With each manifest it +// delivers the authority's own document, signed by signer: what each method of +// the module declares, read by methods from the module's package, and the +// actions on each of its own resource kinds, read by ceilings from its module +// contract (core's manifest names the kinds alone), and the roles a solution +// declares, read by roles from its module contract too (core's manifest +// carries none). +func NewCompositionDelivery(conn grpc.ClientConnInterface, signer solutionhost.Signer, + methods func(ctx context.Context, module string) ([]*compositionv1.MethodPolicyV1, bool, error), + ceilings func(ctx context.Context, module string) ([]*authorityv1.WorkScopeV1, error), + roles func(ctx context.Context, module string) ([]*compositionv1.RoleV1, error)) *CompositionDelivery { + return &CompositionDelivery{client: compositionv1.NewCompositionServiceClient(conn), signer: signer, methods: methods, ceilings: ceilings, roles: roles, applied: map[string]AppliedManifest{}} +} + +// Post sends an already-settled carrier, reading its policy now. Native runs +// use Prepare instead so policy participates in generation settlement. +func (c *CompositionDelivery) Post(ctx context.Context, module string, signed *solutionhost.Signed, token string) (Receipt, error) { + manifest, err := solutionhost.ManifestFromVerified(signed.Document) + if err != nil { + return Receipt{}, err + } + prepared, err := c.Prepare(ctx, module, manifest.Removed) + if err != nil { + return Receipt{}, err + } + return prepared.Post(ctx, module, signed, token) +} + +func (c *CompositionDelivery) post(ctx context.Context, module string, signed *solutionhost.Signed, token string, declaration []byte) (Receipt, error) { + manifest, err := solutionhost.ManifestFromVerified(signed.Document) + if err != nil { + return Receipt{}, err + } + request := &compositionv1.ApplyManifestRequest{ + Carrier: &compositionv1.SignedManifestV1{Document: signed.Document, Envelope: signed.Envelope}, + Credential: token, + } + if request.Authority, err = c.authority(manifest, signed.Document, declaration); err != nil { + return Receipt{}, err + } + out, err := c.client.ApplyManifest(ctx, request) + if err != nil { + return Receipt{Status: httpStatusOf(err), Error: err.Error()}, &HostStatusError{Status: httpStatusOf(err), + Message: fmt.Sprintf("the host refused the module manifest of %s through the composition interface: %v", module, err)} + } + if out.GetGeneration() != manifest.Generation { + return Receipt{}, fmt.Errorf("the composition host acknowledged generation %d for binding %s, sent %d", out.GetGeneration(), manifest.Binding, manifest.Generation) + } + digest, err := manifest.Digest() + if err != nil { + return Receipt{}, err + } + c.mu.Lock() + if held, ok := c.applied[manifest.Binding]; !ok || held.Generation <= manifest.Generation { + c.applied[manifest.Binding] = AppliedManifest{Binding: manifest.Binding, Generation: manifest.Generation, Digest: digest, Release: manifest.Release.Identity(), Removed: manifest.Removed} + } + c.mu.Unlock() + return Receipt{Status: http.StatusOK, Disposition: "accepted", Generation: out.GetGeneration()}, nil +} + +// Prepare captures one declaration. Deterministic bytes both identify its +// generation and supply the signed sidecar; caller-owned protobufs cannot +// change the captured policy during credential acquisition or signing. +func (c *CompositionDelivery) Prepare(ctx context.Context, module string, removed bool) (*PreparedDelivery, error) { + var declaration []byte + if !removed && c.signer != nil { + var err error + declaration, err = c.declaration(ctx, module) + if err != nil { + return nil, err + } + } + // Presence is significant even when the declaration serializes empty. + content := []byte{0} + if declaration != nil { + content = append([]byte{1}, declaration...) + } + return &PreparedDelivery{Content: content, Post: func(ctx context.Context, module string, signed *solutionhost.Signed, token string) (Receipt, error) { + return c.post(ctx, module, signed, token, declaration) + }}, nil +} + +func (c *CompositionDelivery) declaration(ctx context.Context, module string) ([]byte, error) { + var methods []*compositionv1.MethodPolicyV1 + declared := false + if c.methods != nil { + var err error + if methods, declared, err = c.methods(ctx, module); err != nil { + return nil, err + } + } + var ceilings []*authorityv1.WorkScopeV1 + if c.ceilings != nil { + var err error + if ceilings, err = c.ceilings(ctx, module); err != nil { + return nil, err + } + } + var roles []*compositionv1.RoleV1 + if c.roles != nil { + var err error + if roles, err = c.roles(ctx, module); err != nil { + return nil, err + } + } + if !declared && len(ceilings) == 0 && len(roles) == 0 { + return nil, nil + } + return proto.MarshalOptions{Deterministic: true}.Marshal(&compositionv1.AuthorityManifestV1{ + Methods: methods, ScopeCeilings: ceilings, Roles: roles, + }) +} + +// authority binds the captured declaration to the final Core bytes and signs it. +func (c *CompositionDelivery) authority(manifest *solutionhost.ModuleManifest, core, declaration []byte) (*compositionv1.SignedManifestV1, error) { + if declaration == nil { + return nil, nil + } + var policy compositionv1.AuthorityManifestV1 + if err := proto.Unmarshal(declaration, &policy); err != nil { + return nil, err + } + policy.Module, policy.Generation, policy.CoreManifestDigest = manifest.Alias, manifest.Generation, delivery.Digest(core) + document, err := proto.MarshalOptions{Deterministic: true}.Marshal(&policy) + if err != nil { + return nil, err + } + envelope, err := delivery.Sign(document, c.signer.KeyID(), c.signer.Sign) + if err != nil { + return nil, err + } + return &compositionv1.SignedManifestV1{Document: document, Envelope: envelope}, nil +} + +// Applied is NativeDeliverer.Applied over the interface: ApplyManifest applies +// synchronously. Retain the acknowledged request identity for completion in +// this delivery attempt; this is not a remote state query across host restarts. +func (c *CompositionDelivery) Applied(_ context.Context, binding string) (*AppliedManifest, bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + applied, ok := c.applied[binding] + if !ok { + return nil, false, nil + } + return &applied, true, nil +} + +// httpStatusOf maps a refusal's code onto the status the run's classification +// reads: a lower generation and a refused carrier are final, an unreachable host +// is transient. +func httpStatusOf(err error) int { + switch status.Code(err) { + case codes.FailedPrecondition: + return http.StatusConflict + case codes.NotFound: + return http.StatusNotFound + case codes.PermissionDenied, codes.Unauthenticated: + return http.StatusForbidden + case codes.InvalidArgument: + return http.StatusBadRequest + case codes.Unavailable, codes.DeadlineExceeded: + return http.StatusServiceUnavailable + } + return http.StatusInternalServerError +} diff --git a/pkg/gitops/composition_delivery_test.go b/pkg/gitops/composition_delivery_test.go new file mode 100644 index 000000000..66578069e --- /dev/null +++ b/pkg/gitops/composition_delivery_test.go @@ -0,0 +1,203 @@ +package gitops + +import ( + "context" + "errors" + "fmt" + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + "net" + "net/http" + "testing" + + "github.com/codefly-dev/core/solutionhost" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" +) + +// compositionStub is a host that implements codefly.dev/composition: it applies +// a manifest it has not seen at a higher generation and refuses a lower one by +// name, as the suite requires of every implementation. +type compositionStub struct { + compositionv1.UnimplementedCompositionServiceServer + held map[string]uint64 + seen []*compositionv1.ApplyManifestRequest + replyGeneration uint64 +} + +func (s *compositionStub) ApplyManifest(_ context.Context, req *compositionv1.ApplyManifestRequest) (*compositionv1.ApplyManifestResponse, error) { + s.seen = append(s.seen, req) + if req.GetCredential() == "" { + return nil, status.Error(codes.PermissionDenied, "no delivery credential") + } + manifest, err := solutionhost.ManifestFromVerified(req.GetCarrier().GetDocument()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if manifest.Generation <= s.held[manifest.Binding] { + return nil, status.Errorf(codes.FailedPrecondition, "generation %d does not move %s forward", manifest.Generation, manifest.Binding) + } + s.held[manifest.Binding] = manifest.Generation + generation := manifest.Generation + if s.replyGeneration != 0 { + generation = s.replyGeneration + } + return &compositionv1.ApplyManifestResponse{Principal: manifest.Alias, Generation: generation}, nil +} + +func TestCompositionDeliveryCarriesTheSignedManifestThroughApplyManifest(t *testing.T) { + stub := &compositionStub{held: map[string]uint64{}} + server := grpc.NewServer() + compositionv1.RegisterCompositionServiceServer(server, stub) + listener := bufconn.Listen(1 << 20) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + delivery := NewCompositionDelivery(conn, nil, nil, nil, nil) + manifest := &solutionhost.ModuleManifest{ + Schema: solutionhost.SchemaModuleManifestV1, Binding: "probe", Alias: "probe", Kind: solutionhost.KindModule, Generation: 2, + Host: solutionhost.HostTarget{Coordinate: "local", Domain: "local"}, Release: solutionhost.Release{Publisher: "test", Name: "probe", Version: "0.1.0"}, + Workloads: []solutionhost.Workload{{Service: "api", Container: "app"}}, Authority: &solutionhost.Authority{Principal: "probe"}, + } + document, err := solutionhost.SignedPayload(manifest) + if err != nil { + t.Fatal(err) + } + signed := &solutionhost.Signed{Document: document, Envelope: []byte(`{"envelope":"the test host does not verify"}`)} + ctx := context.Background() + receipt, err := delivery.Post(ctx, "probe", signed, "delivery-credential") + if err != nil || receipt.Status != http.StatusOK || receipt.Generation != 2 || receipt.Disposition != "accepted" { + t.Fatalf("post: %+v %v", receipt, err) + } + if stub.seen[0].GetCredential() != "delivery-credential" || string(stub.seen[0].GetCarrier().GetDocument()) != string(document) { + t.Fatal("the carrier and the credential reach the host as sent") + } + if stub.seen[0].GetAuthority() != nil { + t.Fatal("with no method source, no authority document is sent") + } + applied, found, err := delivery.Applied(ctx, "probe") + if err != nil || !found || applied.Generation != 2 { + t.Fatalf("applied: %+v %v %v", applied, found, err) + } + if _, found, _ := delivery.Applied(ctx, "never-delivered"); found { + t.Fatal("an undelivered binding is not applied") + } + manifest.Generation = 1 + document, _ = solutionhost.SignedPayload(manifest) + _, err = delivery.Post(ctx, "probe", &solutionhost.Signed{Document: document, Envelope: signed.Envelope}, "delivery-credential") + var refusal *HostStatusError + if !asHostStatus(err, &refusal) || refusal.Status != http.StatusConflict { + t.Fatalf("a lower generation is a final refusal (409): %v", err) + } + _, err = delivery.Post(ctx, "probe", signed, "") + if !asHostStatus(err, &refusal) || refusal.Status != http.StatusForbidden { + t.Fatalf("no credential is forbidden (403): %v", err) + } +} + +func asHostStatus(err error, target **HostStatusError) bool { + for err != nil { + if e, ok := err.(*HostStatusError); ok { + *target = e + return true + } + u, ok := err.(interface{ Unwrap() error }) + if !ok { + return false + } + err = u.Unwrap() + } + return false +} + +func TestCompositionWithdrawalDoesNotReadOrDeliverLivePolicies(t *testing.T) { + for _, kind := range []solutionhost.Kind{solutionhost.KindModule, solutionhost.KindSolution} { + t.Run(string(kind), func(t *testing.T) { + host := &compositionStub{held: map[string]uint64{}} + server := grpc.NewServer() + compositionv1.RegisterCompositionServiceServer(server, host) + listener := bufconn.Listen(1 << 20) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + conn, err := grpc.NewClient("passthrough:///bufconn", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + sourceReads := 0 + missing := func() error { sourceReads++; return errors.New("removed module source no longer available") } + signer := nativeSigner(t) + delivery := NewCompositionDelivery(conn, signer, + func(context.Context, string) ([]*compositionv1.MethodPolicyV1, bool, error) { + return nil, false, missing() + }, + func(context.Context, string) ([]*authorityv1.WorkScopeV1, error) { return nil, missing() }, + func(context.Context, string) ([]*compositionv1.RoleV1, error) { return nil, missing() }) + manifest := &solutionhost.ModuleManifest{Schema: solutionhost.SchemaModuleManifestV1, Binding: "probe", Alias: "probe", Kind: kind, Generation: 2, Removed: true, + Host: solutionhost.HostTarget{Coordinate: "local", Domain: "local"}, Release: solutionhost.Release{Publisher: "test", Name: "probe", Version: "0.1.0"}} + payload, err := solutionhost.SignedPayload(manifest) + if err != nil { + t.Fatal(err) + } + envelope, err := solutionhost.SignEnvelope(payload, signer) + if err != nil { + t.Fatal(err) + } + signed, err := solutionhost.Carrier(payload, envelope) + if err != nil { + t.Fatal(err) + } + receipt, err := delivery.Post(context.Background(), "probe", signed, "delivery-credential") + if err != nil { + t.Fatal(err) + } + native := &NativeDeliverer{Applied: delivery.Applied} + if err := native.WaitForWithdrawal(t.Context(), manifest); err != nil { + t.Fatalf("interface receipt did not prove withdrawal: %v", err) + } + if receipt.Generation != 2 || sourceReads != 0 || len(host.seen) != 1 || host.seen[0].Authority != nil { + t.Fatalf("withdrawal depends on live policy: reads=%d receipt=%+v", sourceReads, receipt) + } + }) + } +} + +func TestCompositionDeliveryRefusesAMismatchedAcknowledgement(t *testing.T) { + for _, generation := range []uint64{1, 3} { + t.Run(fmt.Sprint(generation), func(t *testing.T) { + host := &compositionStub{held: map[string]uint64{}, replyGeneration: generation} + server := grpc.NewServer() + compositionv1.RegisterCompositionServiceServer(server, host) + listener := bufconn.Listen(1 << 20) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + conn, err := grpc.NewClient("passthrough:///bufconn", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + delivery := NewCompositionDelivery(conn, nil, nil, nil, nil) + manifest := &solutionhost.ModuleManifest{Schema: solutionhost.SchemaModuleManifestV1, Binding: "probe", Alias: "probe", Kind: solutionhost.KindModule, Generation: 2, Removed: true, Host: solutionhost.HostTarget{Coordinate: "local", Domain: "local"}, Release: solutionhost.Release{Publisher: "test", Name: "probe", Version: "0.1.0"}} + document, err := solutionhost.SignedPayload(manifest) + if err != nil { + t.Fatal(err) + } + _, err = delivery.Post(t.Context(), "probe", &solutionhost.Signed{Document: document, Envelope: []byte(`{"test":true}`)}, "credential") + if err == nil { + t.Fatal("wrong generation was treated as applied") + } + if _, found, err := delivery.Applied(t.Context(), "probe"); err != nil || found { + t.Fatalf("mismatched reply populated applied record: %v %v", found, err) + } + }) + } +} diff --git a/pkg/gitops/dev.go b/pkg/gitops/dev.go index 057b6a53c..1d0416a04 100644 --- a/pkg/gitops/dev.go +++ b/pkg/gitops/dev.go @@ -194,6 +194,9 @@ func DeployDev(ctx context.Context, request *DevRequest) (DevResult, error) { if err != nil { return DevResult{}, err } + if err = validateModulePermissions(ctx, request.Workspace, request.Module, request.Environment, service); err != nil { + return DevResult{}, err + } // A configuration error refuses the deploy before the image is built. if err = orchestration.PlanConfigurationReferences(ctx, request.Workspace, request.Environment, []*resources.Service{service}, true); err != nil { return DevResult{}, err diff --git a/pkg/gitops/injection_projection.go b/pkg/gitops/injection_projection.go index 77bb3f5d6..c17e81cf2 100644 --- a/pkg/gitops/injection_projection.go +++ b/pkg/gitops/injection_projection.go @@ -119,7 +119,7 @@ func projectServiceInjectionInTree(ctx context.Context, tree *renderRoot, root, if err := validateInjectionClassification(service, injection); err != nil { return err } - if len(injection.Secrets) > 0 && env.ServiceSecrets == nil { + if len(injection.Secrets) > 0 && !env.ServiceSecrets.Stored() { return fmt.Errorf( "service %q needs %s, which a render carries only as references into the environment's secret store, and environment %q declares no service secret store", service, strings.Join(sortedKeys(injection.Secrets), ", "), env.Name) diff --git a/pkg/gitops/method_policies.go b/pkg/gitops/method_policies.go new file mode 100644 index 000000000..8241f88b2 --- /dev/null +++ b/pkg/gitops/method_policies.go @@ -0,0 +1,427 @@ +package gitops + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/codefly-dev/core/composition" + modulecontract "github.com/codefly-dev/core/contracts/module" + "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/descriptorpb" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + compositionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/composition/v1" + hopv1 "github.com/codefly-dev/authority/libraries/hop/go/gen/authority/hop/v1" +) + +// MethodPolicies reads what each method a module's published protobuf contracts +// serve declares with the authority's policy option, from the module package +// at dir. Every served method is listed, a method with no mark with neither +// field set, so the authority tells "declares nothing" from "not served". A +// method a published descriptor declares but no endpoint of the module serves +// (a protocol another module serves, say) is not listed: the module answers +// for no call to it. A module with no package declares no method policy: ok is +// false. +func MethodPolicies(dir string) (methods []*compositionv1.MethodPolicyV1, ok bool, err error) { + served, ok, err := ServiceMethodPolicies(dir) + if err != nil || !ok { + return nil, ok, err + } + return served.All(), true, nil +} + +// All is the whole module's table: every service's served methods, each +// once, and the foreign methods, sorted by method. +func (served *ServedMethodPolicies) All() []*compositionv1.MethodPolicyV1 { + methods := append([]*compositionv1.MethodPolicyV1(nil), served.Foreign...) + seen := map[string]bool{} + for _, service := range served.Services { + for _, m := range served.ByService[service] { + if !seen[m.GetMethod()] { + seen[m.GetMethod()] = true + methods = append(methods, m) + } + } + } + sort.Slice(methods, func(i, j int) bool { return methods[i].GetMethod() < methods[j].GetMethod() }) + return methods +} + +// ServedMethodPolicies is a module's method policies by where they are +// served: each service's published contracts, and the module contract's +// foreign methods, which name no service. +type ServedMethodPolicies struct { + // Services are the package's services that publish a contract, in + // manifest order. + Services []string + ByService map[string][]*compositionv1.MethodPolicyV1 + Foreign []*compositionv1.MethodPolicyV1 + // Unserved are the methods a published descriptor declares that no + // endpoint of the module serves, sorted: left out of every table, so a + // caller can say so. + Unserved []string +} + +// ServiceMethodPolicies reads the same policies as MethodPolicies, kept by +// the service whose published contract serves them. A method two services +// serve must carry one policy, and a foreign method no published contract may +// serve, exactly as MethodPolicies refuses. +// +// What a protobuf endpoint serves is what the API contract catalog +// (contracts/api/catalog.codefly.json) records for it: the services and +// procedures its listener registers, which `codefly generate contracts` writes +// from the module's declared surface, or as every service of the endpoint's +// package when it declares none. A descriptor carries more than that, its +// package's other services and its imports', so the rest is Unserved. A +// package with no catalog falls back to that same default. +func ServiceMethodPolicies(dir string) (served ServedMethodPolicies, ok bool, err error) { + foreign, err := foreignMethods(dir) + if err != nil { + return served, false, err + } + served.Foreign = foreign + served.ByService = map[string][]*compositionv1.MethodPolicyV1{} + if _, statErr := os.Stat(filepath.Join(dir, composition.PackageManifestFileName)); os.IsNotExist(statErr) { + return served, len(foreign) > 0, nil + } + manifest, err := composition.LoadPackageManifest(dir) + if err != nil { + return served, false, err + } + catalog, err := loadServedCatalog(dir) + if err != nil { + return served, false, err + } + seen := map[string]*compositionv1.MethodPolicyV1{} + unserved := map[string]bool{} + for index := range manifest.Services { + service := &manifest.Services[index] + own := map[string]bool{} + for _, contract := range service.APIContracts { + var methods []*compositionv1.MethodPolicyV1 + switch contract.Kind { + case composition.APIContractKindProtobuf: + serves, servesErr := endpointServes(catalog, service.Name, &contract) + if servesErr != nil { + return served, false, fmt.Errorf("module package %s, service %s: %w", manifest.ID, service.Name, servesErr) + } + var skipped []string + methods, skipped, err = contractMethods(filepath.Join(dir, contract.Path), contract.Digest, serves) + for _, method := range skipped { + unserved[method] = true + } + case composition.APIContractKindOpenAPI: + methods, err = routeMethods(filepath.Join(dir, contract.Path), contract.Digest) + default: + continue + } + if err != nil { + return served, false, fmt.Errorf("module package %s, service %s: %w", manifest.ID, service.Name, err) + } + for _, m := range methods { + if previous := seen[m.GetMethod()]; previous == nil { + seen[m.GetMethod()] = m + } else if !proto.Equal(previous, m) { + return served, false, fmt.Errorf("module package %s: conflicting policies for %s", manifest.ID, m.GetMethod()) + } + if !own[m.GetMethod()] { + own[m.GetMethod()] = true + served.ByService[service.Name] = append(served.ByService[service.Name], m) + } + } + } + if methods := served.ByService[service.Name]; len(methods) > 0 { + sort.Slice(methods, func(i, j int) bool { return methods[i].GetMethod() < methods[j].GetMethod() }) + served.Services = append(served.Services, service.Name) + } + } + // A foreign method is one no published contract serves: a module that + // declares one it also publishes would have two answers for one method. + for _, m := range foreign { + if seen[m.GetMethod()] != nil { + return served, false, fmt.Errorf("module package %s declares foreign method %s, which its published contracts already serve", manifest.ID, m.GetMethod()) + } + seen[m.GetMethod()] = m + } + // A method one endpoint serves and another's descriptor merely carries is + // served. + for method := range unserved { + if seen[method] == nil { + served.Unserved = append(served.Unserved, method) + } + } + sort.Strings(served.Unserved) + return served, true, nil +} + +// loadServedCatalog reads the module's API contract catalog, or nil when the +// package has none. +func loadServedCatalog(dir string) (*composition.APIContractCatalog, error) { + catalog, err := composition.LoadAPIContractCatalog(dir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return catalog, err +} + +// endpointServes answers which of a protobuf contract's methods its endpoint +// serves, by the package a method's service is declared in and its procedure: +// the procedures the catalog records for the endpoint, or with no catalog every +// service of the contract's package, the catalog's own default. A catalog that +// records the endpoint under another digest, or not at all, is stale. +func endpointServes(catalog *composition.APIContractCatalog, service string, contract *composition.ProvidedAPIContract) (func(pkg, procedure string) bool, error) { + if catalog == nil { + if contract.Package == "" { + return nil, fmt.Errorf("endpoint %s/%s has no single protobuf package and requires %s to identify its served procedures; run codefly generate contracts", service, contract.Endpoint, composition.APIContractCatalogFileName) + } + return func(pkg, _ string) bool { return pkg == contract.Package }, nil + } + for index := range catalog.Endpoints { + endpoint := &catalog.Endpoints[index] + if endpoint.Service != service || endpoint.Endpoint != contract.Endpoint { + continue + } + if endpoint.Digest != contract.Digest { + return nil, fmt.Errorf("%s records endpoint %s/%s as %s, the package names %s; run codefly generate contracts", composition.APIContractCatalogFileName, service, contract.Endpoint, endpoint.Digest, contract.Digest) + } + procedures := map[string]bool{} + for _, s := range endpoint.Services { + for _, procedure := range s.Procedures { + procedures[procedure] = true + } + } + return func(_, procedure string) bool { return procedures[procedure] }, nil + } + return nil, fmt.Errorf("%s records no endpoint %s/%s; run codefly generate contracts", composition.APIContractCatalogFileName, service, contract.Endpoint) +} + +// foreignMethods reads the methods the module at dir serves from a protocol +// it does not own, from its module contract, each with what a call requires. +// A module that publishes no contract declares none. +func foreignMethods(dir string) ([]*compositionv1.MethodPolicyV1, error) { + contract, err := modulecontract.Load(dir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var out []*compositionv1.MethodPolicyV1 + for _, method := range contract.ForeignMethods { + m := &compositionv1.MethodPolicyV1{Method: method.Method} + for _, required := range method.Requires { + m.Requires = append(m.Requires, &authorityv1.WorkScopeV1{ResourceKind: required.ResourceKind, Actions: required.Actions}) + } + out = append(out, m) + } + return out, nil +} + +// contractMethods reads one published contract, refusing one whose bytes are +// not the digest the package names. Only the methods serves admits are read; +// the others are returned as unserved, with no policy read and no route. A +// method bound to HTTP routes +// (google.api.http) is listed under each route too, "