diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index a251e504f..c215a763d 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -1,18 +1,6 @@ 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: @@ -26,20 +14,13 @@ 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}" - 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 + GOWORK=off 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 deleted file mode 100644 index 281244086..000000000 --- a/.github/actions/read-authority/action.yml +++ /dev/null @@ -1,39 +0,0 @@ -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 a42abe4c5..c11720fa5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -56,7 +56,6 @@ 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 deleted file mode 100755 index d0ef50e7e..000000000 --- a/.github/scripts/download-go-modules.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/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 058e7a1a3..5ef3bf484 100644 --- a/.github/workflows/companions.yaml +++ b/.github/workflows/companions.yaml @@ -36,12 +36,6 @@ 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 @@ -88,16 +82,9 @@ 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 - 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 + run: GOWORK=off 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 @@ -241,16 +228,9 @@ 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 - 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 + run: GOWORK=off 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 2574f56e2..a93d3933b 100644 --- a/.github/workflows/container-recovery-native.yml +++ b/.github/workflows/container-recovery-native.yml @@ -14,12 +14,6 @@ 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 @@ -59,14 +53,6 @@ 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 5e908ba34..f40fb8332 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,16 +22,6 @@ 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 }} @@ -98,22 +88,15 @@ 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 - env: - AUTHORITY_TOKEN: ${{ secrets.GH_PAT }} - run: .github/scripts/download-go-modules.sh + run: go mod download # govulncheck gate via the CLI (replaces scripts/govulncheck.sh): # suppressions + staleness from .govulncheck.yaml, fails only on @@ -303,14 +286,6 @@ 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 c6d0689bb..0689f3b2b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -15,16 +15,6 @@ 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 @@ -42,17 +32,6 @@ 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 @@ -101,8 +80,6 @@ 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" \ @@ -112,8 +89,6 @@ 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" \ @@ -160,8 +135,6 @@ 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/AGENTS.md b/AGENTS.md index 16781d93a..b67281463 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - **Module:** `github.com/codefly-dev/cli` - **Repository:** `https://github.com/codefly-dev/cli` -- **Language:** Go 1.27.2 (`go.mod` is the source of truth — see [Bump the Go version](docs/runbooks/bump-go-version.md)) +- **Language:** Go 1.27.3 (`go.mod` is the source of truth — see [Bump the Go version](docs/runbooks/bump-go-version.md)) The CLI is the primary interface for codefly. It orchestrates agent lifecycles, manages the daemon, runs services with their dependency graphs, and exposes an MCP server for AI tool diff --git a/README.md b/README.md index f7c0882b2..8c051938a 100644 --- a/README.md +++ b/README.md @@ -22,20 +22,6 @@ 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 7f189a552..e7dccfb0b 100644 --- a/cmd/ci/deploy.go +++ b/cmd/ci/deploy.go @@ -4,10 +4,9 @@ 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/gitops" "github.com/codefly-dev/cli/pkg/orchestration" "github.com/codefly-dev/cli/pkg/platform" "github.com/codefly-dev/core/resources" @@ -72,7 +71,12 @@ 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, orchestration.WithCompositionCheck(gitops.PermissionPreflight(true)), orchestration.WithRunModuleClosure(module.Name)) + // The modules `deploy service` checks, the same way, before any effect; the + // deploy graph itself is the one a deployment has always taken. + if err = gitops.CheckCompositionPermissions(ctx, workspace, env, true, module.Name); err != nil { + return nil, nil, w.Wrap(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/doctor_federation_test.go b/cmd/doctor_federation_test.go new file mode 100644 index 000000000..b94bd07df --- /dev/null +++ b/cmd/doctor_federation_test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" +) + +func TestDoctorReportsNativeFederationWithoutRequestingAFile(t *testing.T) { + workspace := testWorkspaceYAML + `environments: + - name: local + host: + coordinate: owner/local/host + component: backend + domain: owner + audience: host + trust_domain: host.local + delivery: backend/api/grpc +` + for _, delivery := range []string{"backend/api/grpc", "other/api/grpc"} { + t.Run(delivery, func(t *testing.T) { + dir := singleServiceWorkspace(t, strings.ReplaceAll(workspace, "backend/api/grpc", delivery), []string{"federation"}, nil) + report := runReadiness(t, workspaceReadinessOptions{dir: dir}) + if delivery == "other/api/grpc" { + requireCode(t, report, codeConfigurationMissing, "fail") + return + } + requireNoCode(t, report, codeConfigurationMissing) + requireNoCode(t, report, codeConfigurationDirMissing) + require.Contains(t, reportJSON(t, report), "container runs and deployments still require authored federation") + }) + } +} + +func TestDoctorNativeFederationDoesNotCoverAnotherConsumer(t *testing.T) { + host := &resources.Service{Name: "api", WorkspaceConfigurationDependencies: []string{"federation"}} + host.WithModule("backend") + other := &resources.Service{Name: "worker", WorkspaceConfigurationDependencies: []string{"federation"}} + other.WithModule("backend") + env := &environments.Environment{Host: &environments.EnvironmentHost{Coordinate: "owner/local/host", Domain: "owner", Delivery: "backend/api/grpc"}} + require.True(t, nativeFederationCoversScope(env, []*resources.Service{host})) + require.False(t, nativeFederationCoversScope(env, []*resources.Service{host, other})) + require.False(t, nativeFederationCoversScope(env, nil)) +} diff --git a/cmd/doctor_workspace.go b/cmd/doctor_workspace.go index 309efe466..159ed4edb 100644 --- a/cmd/doctor_workspace.go +++ b/cmd/doctor_workspace.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "regexp" + "slices" "sort" "strings" "sync" @@ -1090,6 +1091,14 @@ func checkConfigurationSources(ctx context.Context, ws *resources.Workspace, env if provided != nil { declared := ownConfigurationNames(wsCfgDir) byName, own, composed := classifyProvidedConfigurations(provided, declared) + // Doctor has no selected runtime. Report native-only supply as a warning, + // and exempt no other service that also requires the authored group. + if byName[environments.FederationConfiguration] == nil && provided.Ambiguous[environments.FederationConfiguration] == nil && + nativeFederationCoversScope(env, scope) { + required = slices.DeleteFunc(required, func(name string) bool { return name == environments.FederationConfiguration }) + report.add("", "workspace configuration federation", "warn", + "derived from host for the native delivery service; container runs and deployments still require authored federation values", "") + } reportWorkspaceConfigurationDirectory(wsCfgDir, relCfgDir, required, byName, own, report) if len(composed) > 0 { report.add("", "composed module configurations", "ok", @@ -1117,6 +1126,26 @@ func checkConfigurationSources(ctx context.Context, ws *resources.Workspace, env return append(toResolve, checkServiceConfigurationSources(ctx, env, profiles, profile, scope, report)...), provided } +// nativeFederationCoversScope requires every declaring consumer to be the +// delivery host. A generated host group never satisfies a sibling's requirement. +func nativeFederationCoversScope(env *environments.Environment, scope []*resources.Service) bool { + if env == nil || env.DeclaresLocalK3d() { + return false + } + found := false + for _, service := range scope { + if !slices.Contains(service.WorkspaceConfigurationDependencies, environments.FederationConfiguration) { + continue + } + group, err := environments.NativeHostFederation(env.Host, service) + if err != nil || group == nil { + return false + } + found = true + } + return found +} + // reportWorkspaceConfigurationDirectory reports the workspace's own // configuration directory. Only what nobody provides — a name no module // ships, or one two modules disagree on — is what the directory would have to diff --git a/cmd/run/artifact_cache_test.go b/cmd/run/artifact_cache_test.go index 35990150a..f5fe0d0fa 100644 --- a/cmd/run/artifact_cache_test.go +++ b/cmd/run/artifact_cache_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/codefly-dev/cli/pkg/composition" "github.com/codefly-dev/cli/pkg/environments" @@ -45,65 +46,156 @@ func TestArtifactCacheGroupCreatesTheCacheRoot(t *testing.T) { require.True(t, info.IsDir()) } -// 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() +// pageModule is a component whose entry service declares a page, with its npm +// project (package.json, package-lock.json, one source) beside the services. +func pageModule(t *testing.T) (*resources.Module, string) { + t.Helper() module := &resources.Module{Name: "wiki", ServiceEntry: "api"} module.WithDir(t.TempDir()) entry := filepath.Join(module.ServicesDir(), "api") require.NoError(t, os.MkdirAll(entry, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entry, solutionrun.FrontendFileName), []byte("title: Wiki\norder: 1\nexposed_module: ./Page\nhost_contract: 1\n"), 0o600)) + remote := filepath.Join(module.ServicesDir(), "fe-remote") + require.NoError(t, os.MkdirAll(remote, 0o755)) + for name, content := range map[string]string{"package.json": `{"scripts":{"build":"owner-build"}}`, "package-lock.json": `{"lockfileVersion":3}`, "Page.tsx": "page v1"} { + require.NoError(t, os.WriteFile(filepath.Join(remote, name), []byte(content), 0o600)) + } + return module, remote +} + +// fakeTool puts an executable named name, running body, first on PATH. +func fakeTool(t *testing.T, bin, name, body string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nset -eu\n"+body), 0o700)) +} + +// A declared page is built natively with the frontend's own npm build, once per +// change of its own inputs: an unchanged frontend, or an edit elsewhere in the +// component (a Go service hot-reloading), reuses the retained bundle; a source +// edit rebuilds; a lockfile edit installs again first. A failed build names its +// command and carries its output, and never delivers an old bundle. +func TestANativeRunBuildsItsDeclaredPageOncePerChangeOfItsInputs(t *testing.T) { + ctx := context.Background() + module, remote := pageModule(t) workspace := &resources.Workspace{Name: "acme"} workspace.WithDir(t.TempDir()) - cluster := &launcher{workspace: workspace} + cluster := &launcher{workspace: workspace, stateDir: t.TempDir(), flow: &fakeLauncherFlow{}} - bundle, err := cluster.frontendBundle(ctx, module) + headless := &resources.Module{Name: "headless", ServiceEntry: "api"} + headless.WithDir(t.TempDir()) + bundle, err := cluster.frontendBundle(ctx, headless) 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)) - 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) + calls := filepath.Join(t.TempDir(), "calls") + t.Setenv("ASSET_PREFIX", "http://leaked.example") + fakeTool(t, bin, "npm", `echo "$*" >> `+calls+` +[ -z "${ASSET_PREFIX:-}" ] || { echo 'ASSET_PREFIX reached the build' >&2; exit 3; } +case "$*" in +ci) mkdir -p node_modules ;; +"run build") mkdir -p dist && printf '{"page":"%s"}' "$(cat Page.tsx)" > dist/mf-manifest.json ;; +esac +`) + build := func() string { + t.Helper() + bundle, err := cluster.frontendBundle(ctx, module) + require.NoError(t, err) + archive := filepath.Join(composition.ArtifactCacheDir(workspace.Dir()), strings.TrimPrefix(string(bundle.Digest), "sha256:")) + contents, err := os.ReadFile(archive) + require.NoError(t, err) + return string(contents) + } + invocations := func() []string { + data, _ := os.ReadFile(calls) + _ = os.Remove(calls) + return strings.Fields(strings.ReplaceAll(strings.TrimSpace(string(data)), "run build", "build")) + } + + require.Contains(t, build(), "page v1") + require.Equal(t, []string{"ci", "build"}, invocations()) + require.Contains(t, build(), "page v1") + require.Empty(t, invocations(), "unchanged inputs reuse the retained bundle") + require.NoError(t, os.WriteFile(filepath.Join(module.ServicesDir(), "api", "main.go"), []byte("package main"), 0o600)) + require.Contains(t, build(), "page v1") + require.Empty(t, invocations(), "an edit outside the frontend builds nothing") + require.NoError(t, os.WriteFile(filepath.Join(remote, "Page.tsx"), []byte("page v2"), 0o600)) + require.Contains(t, build(), "page v2") + require.Equal(t, []string{"build"}, invocations(), "a source edit rebuilds without reinstalling") + require.NoError(t, os.WriteFile(filepath.Join(remote, "package-lock.json"), []byte(`{"lockfileVersion":3,"packages":{}}`), 0o600)) + build() + require.Equal(t, []string{"ci", "build"}, invocations(), "a lockfile edit installs again") + + require.NoError(t, os.WriteFile(filepath.Join(remote, "Page.tsx"), []byte("page v3"), 0o600)) + fakeTool(t, bin, "npm", "echo '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 bundle") + require.ErrorContains(t, err, "build the frontend of wiki (npm run build in") + require.ErrorContains(t, err, "owner bundler: Page.tsx failed") + + require.NoError(t, os.Symlink("Page.tsx", filepath.Join(remote, "Alias.tsx"))) + _, err = cluster.frontendBundle(ctx, module) + require.ErrorContains(t, err, "Alias.tsx is not a regular file") +} + +// A page whose owning service runs in a container is built in the pinned +// builder image, as deployment builds it; the controlled Docker peer exercises +// the process boundary without a registry or toolchain. +func TestAContainerizedPageBuildsInTheBuilderImage(t *testing.T) { + ctx := context.Background() + module, _ := pageModule(t) + workspace := &resources.Workspace{Name: "acme"} + workspace.WithDir(t.TempDir()) + cluster := &launcher{workspace: workspace, stateDir: t.TempDir(), flow: &fakeLauncherFlow{runtime: resources.RuntimeContextContainer}} + bin := t.TempDir() + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + fakeTool(t, bin, "npm", "echo 'npm must not run on the host' >&2\nexit 9\n") + fakeTool(t, bin, "docker", `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) + 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) + contents, err := os.ReadFile(filepath.Join(composition.ArtifactCacheDir(workspace.Dir()), strings.TrimPrefix(string(bundle.Digest), "sha256:"))) 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) + fakeTool(t, bin, "docker", "cat >/dev/null\necho 'owner build returned without artifacts'\n") + require.NoError(t, os.WriteFile(filepath.Join(module.ServicesDir(), "fe-remote", "Page.tsx"), []byte("page v2"), 0o600)) + _, err = cluster.frontendBundle(ctx, module) require.ErrorContains(t, err, "frontend build of wiki wrote no regular mf-manifest.json") require.ErrorContains(t, err, "owner build returned without artifacts") } +// The frontend is built before the delivery lock is taken: a module whose +// build fails says so while another delivery holds the lock. +func TestAFrontendBuildHoldsNoDeliveryLock(t *testing.T) { + root := writeNonGoWorkloadDir(t, "model") + moduleFile := filepath.Join(root, "modules", "model", resources.ModuleConfigurationName) + require.NoError(t, os.WriteFile(moduleFile, []byte("kind: module\nname: model\nservice-entry: api\nservices:\n - name: api\n"), 0o644)) + workspace, err := resources.LoadWorkspaceFromDir(context.Background(), root) + require.NoError(t, err) + module, err := workspace.LoadModuleFromName(context.Background(), "model") + require.NoError(t, err) + require.NotEmpty(t, module.ServiceEntry) + entry := filepath.Join(module.ServicesDir(), module.ServiceEntry) + require.NoError(t, os.MkdirAll(entry, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entry, solutionrun.FrontendFileName), []byte("title: Model\norder: 1\nexposed_module: ./Page\nhost_contract: 1\n"), 0o600)) + cluster, _, unique := launcherWithFakeFlow(t, func(string) bool { return true }) + cluster.workspace, cluster.stateDir = workspace, t.TempDir() + cluster.running[unique] = true + require.NoError(t, cluster.acquireDelivery(context.Background())) + defer cluster.delivery.Release(1) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + require.ErrorContains(t, cluster.deliver(ctx, "model"), "holds no npm project", "the build ran and failed without waiting for the lock") +} + // Forgetting a stopped workload drops what this run kept for it, and only // that: the identity, the projection, the issued workload and whether it // runs; another workload's stays, and so does its module's delivered @@ -175,6 +267,23 @@ func TestTheDeliveryHostReceivesItsFederationSurfaceFromTheHostBlock(t *testing. require.NoError(t, err) require.Empty(t, groups, "a host that does not declare federation is handed none of it") - _, err = federationGroup(&environments.EnvironmentHost{Coordinate: "obin/local/dev"}) + _, err = environments.NativeHostFederation(&environments.EnvironmentHost{Coordinate: "obin/local/dev", Delivery: "saas/accounts/rest"}, accounts) require.ErrorContains(t, err, "host.domain") } + +// A run keeps no staged frontend copy past its teardown, nor past the module +// leaving the composition; the bundle records stay, so retained bundles are +// reused. +func TestStagedFrontendCopiesLeaveWithTheModuleAndTheRun(t *testing.T) { + cluster := &launcher{stateDir: t.TempDir(), stop: make(chan struct{})} + for _, module := range []string{"wiki", "summary"} { + require.NoError(t, os.MkdirAll(filepath.Join(cluster.frontendState(), module, "source", "node_modules"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(cluster.frontendState(), module+".json"), []byte(`{}`), 0o600)) + } + require.NoError(t, cluster.forgetFrontend(context.Background(), "summary")) + require.NoDirExists(t, filepath.Join(cluster.frontendState(), "summary")) + require.DirExists(t, filepath.Join(cluster.frontendState(), "wiki")) + cluster.close() + require.NoDirExists(t, filepath.Join(cluster.frontendState(), "wiki")) + require.FileExists(t, filepath.Join(cluster.frontendState(), "wiki.json")) +} diff --git a/cmd/run/extend.go b/cmd/run/extend.go index eb71ab584..f690bd7d8 100644 --- a/cmd/run/extend.go +++ b/cmd/run/extend.go @@ -72,6 +72,14 @@ func watchForExtensions(ctx context.Context, flow *orchestration.Flow, baseline case <-ctx.Done(): return case <-signals: + workspace, loadErr := common.LoadWorkspace(ctx) + if loadErr == nil { + loadErr = flow.CheckRoutingSnapshot(ctx, workspace) + } + if loadErr != nil { + cli.Warning("the running composition was not changed: %v", loadErr) + continue + } if err := withdrawDroppedRoots(ctx, flow, pinned); err != nil { cli.Warning("composition withdrawal is incomplete; the next SIGUSR1 retries it: %v", err) continue @@ -122,6 +130,9 @@ func withdrawDroppedRoots(ctx context.Context, flow *orchestration.Flow, pinned if err != nil { return fmt.Errorf("reload the workspace: %w", err) } + if err = flow.CheckRoutingSnapshot(ctx, workspace); err != nil { + return err + } roots, err := CompositionRoots(ctx, workspace) if err != nil { return err @@ -155,6 +166,9 @@ func extendRunningWorkspace(ctx context.Context, flow *orchestration.Flow, basel if err != nil { return fmt.Errorf("reload the workspace: %w", err) } + if err = flow.CheckRoutingSnapshot(ctx, workspace); err != nil { + return err + } roots, err := CompositionRoots(ctx, workspace) if err != nil { return err diff --git a/cmd/run/launcher.go b/cmd/run/launcher.go index 0bf366776..2c1735e3e 100644 --- a/cmd/run/launcher.go +++ b/cmd/run/launcher.go @@ -2,11 +2,11 @@ package run import ( "context" + "errors" "fmt" "golang.org/x/sync/semaphore" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" "os" "path/filepath" @@ -74,7 +74,7 @@ type launcher struct { identities map[string]gitops.NativeIdentity signer solutionhost.Signer - hostModule, hostService string + hostModule, hostService, hostEndpoint string // composition guards workspace and identities: the components this run // composes, which admit extends with the ones a reloaded workspace adds @@ -97,8 +97,15 @@ type launcher struct { // pendingWithdrawals are modules whose withdrawal the host has not applied // yet: retried on the next composition refresh, even with no gone roots. pendingWithdrawals map[string]bool - envelope *gitops.EnvelopeWriter - stop chan struct{} + // envelope writes the module envelope through the host's gateway, for a + // host reached through the legacy HTTP delivery. + envelope *gitops.EnvelopeWriter + stop chan struct{} + stopOnce sync.Once + + // host is the run's one connection to the host's composition interface, + // reused by every delivery and withdrawal, and closed with the run. + host compositionHost // schedule bounds each workload's wait for the host; waiters are the // deliveries in flight, one per workload. @@ -115,31 +122,51 @@ var ( // attachLauncher wires the run as the composition's cluster when its // environment declares a host and the run is native. It is a no-op otherwise. func attachLauncher(ctx context.Context, flow *orchestration.Flow, workspace *resources.Workspace, env *environments.Environment, fixture string) error { - if env == nil || env.Host == nil || runtimeContext == resources.RuntimeContextContainer { - return nil - } - w := wool.Get(ctx).In("launcher") launcherMu.Lock() defer launcherMu.Unlock() if launcherActive != nil { launcherActive.close() launcherActive = nil } - if err := env.Host.Validate(); err != nil { + cluster, err := newNativeLauncher(ctx, flow, workspace, env, runtimeContext, fixture) + if err != nil { return err } + launcherActive = cluster + return nil +} + +// AttachNativeLauncher gives test entry points the same native authority +// admission as run, with explicit configuration and flow-owned teardown. +// Call after InitManagers determines whether this test starts any service. +func AttachNativeLauncher(ctx context.Context, flow *orchestration.Flow, workspace *resources.Workspace, env *environments.Environment, selectedRuntime, fixture string) error { + if len(flow.LocalRuntimeServices()) == 0 { + return nil + } + _, err := newNativeLauncher(ctx, flow, workspace, env, selectedRuntime, fixture) + return err +} + +func newNativeLauncher(ctx context.Context, flow *orchestration.Flow, workspace *resources.Workspace, env *environments.Environment, selectedRuntime, fixture string) (*launcher, error) { + if env == nil || env.Host == nil || selectedRuntime == resources.RuntimeContextContainer { + return nil, nil + } + w := wool.Get(ctx).In("launcher") + if err := env.Host.Validate(); err != nil { + return nil, err + } identities, err := gitops.ResolveNativeIdentities(ctx, workspace, env) if err != nil { - return err + return nil, err } signer, err := signing.LoadDeveloperKey() if err != nil { - return fmt.Errorf("a native run delivers with the developer key, and none is held: %w", err) + return nil, fmt.Errorf("a native run delivers with the developer key, and none is held: %w", err) } stateDir := filepath.Join(workspace.Dir(), ".codefly", "run", env.Name) launcherIdentity, err := identity.Start(env.Host.TrustDomain, env.Host.Audience, stateDir) if err != nil { - return err + return nil, err } cluster := &launcher{ vouch: launcherIdentity, workspace: workspace, env: env, flow: flow, fixture: fixture, @@ -152,32 +179,21 @@ func attachLauncher(ctx context.Context, flow *orchestration.Flow, workspace *re stop: make(chan struct{}), schedule: nativeDeliverySchedule, } - parts := strings.Split(env.Host.Delivery, "/") - if len(parts) != 3 { - return fmt.Errorf("host.delivery %q is not //", env.Host.Delivery) - } - cluster.hostModule, cluster.hostService = parts[0], parts[1] + cluster.hostModule, cluster.hostService, cluster.hostEndpoint = env.Host.DeliveryEndpoint() // The run vouches for exactly what it composes: each identity workload, // each module's delivery carrier, and the applied observer it acts as. cluster.declare(identities) if err := cluster.projectHost(); err != nil { - return err + return nil, err } world := flow.World() world.RenderOwnedGroups = cluster.renderOwnedGroups world.RuntimeOverrides = cluster.runtimeOverrides - cluster.registerRunningHook(ctx, flow.WithRunningHook) + world.BeginRuntimeStart = cluster.beginRuntimeStart 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) - }) + world.BeforeRuntimeStop = cluster.close + return cluster, nil } func stopLauncher() { @@ -190,15 +206,41 @@ func stopLauncher() { } func (cluster *launcher) close() { - select { - case <-cluster.stop: - default: - close(cluster.stop) + cluster.stopOnce.Do(func() { + if cluster.stop != nil { + close(cluster.stop) + } + }) + cluster.host.close() + // A run torn down keeps no staged frontend copy: the next run stages and + // installs again, and reuses every bundle still retained. + if cluster.stateDir != "" { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := (solutionrun.LocalFrontend{StateDir: cluster.frontendState()}).ForgetAll(ctx); err != nil { + wool.Get(ctx).In("launcher.close").Warn("cannot remove the staged frontend copies", wool.ErrField(err)) + } } } +// frontendState is where this run keeps its frontend build records and staged +// copies, per environment. +func (cluster *launcher) frontendState() string { + return filepath.Join(cluster.stateDir, "frontend") +} + +// forgetFrontend removes a module's staged frontend copy. +func (cluster *launcher) forgetFrontend(ctx context.Context, module string) error { + if cluster.stateDir == "" { + return nil + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + return solutionrun.LocalFrontend{StateDir: cluster.frontendState()}.Forget(ctx, module) +} + // declare records identity workloads the run vouches for, with their delivery -// carriers and the observer. +// carriers and the observer the legacy HTTP delivery reads applied state as. func (cluster *launcher) declare(identities map[string]gitops.NativeIdentity) { if cluster.vouch == nil { return @@ -338,12 +380,7 @@ func (cluster *launcher) runtimeOverrides(service *resources.Service) map[string if ok { // A module-identity service reads its token from the path a cell's // render mounts unless told otherwise; this is the otherwise. - credential, err := cluster.project(unique, identity) - if err != nil { - wool.Get(context.Background()).In("launcher").Warn("cannot project the module's credential", wool.Field("service", unique), wool.ErrField(err)) - return out - } - out[workloadTokenFileVariable] = credential.File + out[workloadTokenFileVariable] = cluster.vouch.ProjectionPath(identity.Workload(cluster.env.Host.TrustDomain)) } if unique == resources.ServiceUnique(cluster.hostModule, cluster.hostService) { out[nativeidentity.AnchorEnvironment] = cluster.hostIdentityAnchor @@ -361,20 +398,19 @@ func (cluster *launcher) renderOwnedGroups(ctx context.Context, service *resourc // a cell's composition declares for the environment and this run derives // from the host block: handed last, so they settle over what the host's // own authored group leaves empty. - if unique := resources.WithUnique(service).Unique(); unique == resources.ServiceUnique(cluster.hostModule, cluster.hostService) && - slices.Contains(service.WorkspaceConfigurationDependencies, environments.FederationConfiguration) { - group, err := federationGroup(cluster.env.Host) - if err != nil { - return nil, fmt.Errorf("%s: %w", unique, err) - } + group, err := environments.NativeHostFederation(cluster.env.Host, service) + if err != nil { + return nil, err + } + if group != nil { out = append(out, group) } if slices.Contains(service.WorkspaceConfigurationDependencies, environments.ArtifactCacheConfiguration) { - group, err := artifactCacheGroup(cluster.composed().Dir()) - if err != nil { - return nil, err + cacheGroup, cacheErr := artifactCacheGroup(cluster.composed().Dir()) + if cacheErr != nil { + return nil, cacheErr } - out = append(out, group) + out = append(out, cacheGroup) } if !slices.Contains(service.WorkspaceConfigurationDependencies, environments.ModuleAuthorityConfiguration) { return out, nil @@ -396,27 +432,6 @@ func (cluster *launcher) renderOwnedGroups(ctx context.Context, service *resourc return append(out, &basev0.Configuration{Origin: resources.ConfigurationWorkspace, Infos: []*basev0.ConfigurationInformation{authority}}), nil } -// federationGroup is the federation surface a native run hands the delivery -// host: the coordinate it answers for, the ownership domain it accepts a -// binding under (the one the run's own policy admits the run's signer for) -// and the trust policy, pinned keys — the three values a cell's composition -// declares in the host's federation group, derived here from the host block -// the manifests this run delivers are stamped with, so the host accepts what -// the run signs and nothing else. The host's registration secrets stay in its -// authored group, untouched. -func federationGroup(host *environments.EnvironmentHost) (*basev0.Configuration, error) { - if host == nil || host.Coordinate == "" || host.Domain == "" { - return nil, fmt.Errorf("the delivery host answers for the environment's host.coordinate under host.domain, and the environment declares no host with both") - } - return &basev0.Configuration{Origin: resources.ConfigurationWorkspace, Infos: []*basev0.ConfigurationInformation{{ - Name: environments.FederationConfiguration, ConfigurationValues: []*basev0.ConfigurationValue{ - {Key: environments.FederationHostCoordinateKey, Value: host.Coordinate}, - {Key: environments.FederationHostOwnershipDomainsKey, Value: host.Domain}, - {Key: environments.FederationHostTrustPolicyKey, Value: environments.FederationHostTrustPolicyKeys}, - }, - }}}, nil -} - // artifactCacheGroup is the artifact-cache group a native run hands a service // that declares it: ROOT, the absolute path of the workspace's artifact cache, // the read-only source a host's native bundle adapter reads a frontend bundle @@ -434,26 +449,34 @@ func artifactCacheGroup(workspaceDir string) (*basev0.Configuration, error) { }}}, nil } -// 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. +// frontendBundle is the bundle of a module's declared page, built from the +// frontend's own inputs only when they changed since the bundle retained for +// them, and retained in the existing artifact cache for host delivery. It runs +// before the delivery lock is taken, so a build delays no other module's +// delivery. The frontend's own npm build runs natively unless the service that +// owns the page runs in a container, where it builds in the pinned builder +// image as deployment does. Headless components need no build. 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 } - scratch, err := os.MkdirTemp("", "codefly-run-frontend-") - if err != nil { - return nil, fmt.Errorf("prepare the frontend build of %s: %w", module.Name, err) - } - defer func() { _ = os.RemoveAll(scratch) }() - root := filepath.Join(scratch, "bundle") - if err = solutionrun.BuildLocalFrontendBundle(ctx, module, root); err != nil { - return nil, err + workspaceDir := cluster.composed().Dir() + build := solutionrun.LocalFrontend{ + StateDir: cluster.frontendState(), + Docker: cluster.flow.ServiceRuntimeContext(resources.ServiceUnique(module.Name, module.ServiceEntry)) == resources.RuntimeContextContainer, + Retain: func(ctx context.Context, root string) (string, error) { + digest, _, retainErr := composition.RetainCanonicalArchive(ctx, workspaceDir, root) + return digest, retainErr + }, + Retained: func(digest string) bool { + info, statErr := os.Stat(filepath.Join(composition.ArtifactCacheDir(workspaceDir), strings.TrimPrefix(digest, "sha256:"))) + return statErr == nil && info.Mode().IsRegular() + }, } - digest, _, err := composition.RetainCanonicalArchive(ctx, cluster.composed().Dir(), root) + digest, err := build.Bundle(ctx, module) if err != nil { - return nil, fmt.Errorf("retain the frontend bundle of %s: %w", module.Name, err) + return nil, err } return &solutionhost.FrontendPayload{Artifact: gitops.FrontendArtifactName, Digest: solutionhost.PayloadDigest(digest), MediaType: composition.CanonicalArchiveMediaType}, nil } @@ -504,120 +527,165 @@ func (cluster *launcher) refreshTokens(ctx context.Context) { } } -// onRunning is the cell's delivery, for one identity workload that just -// started: its module's manifest, listing it — once the host is ready to take -// it, retrying while it is not serving yet (hostDelivery). A workload that is -// not delivered is said so plainly: the gateway routes nothing to its module. -func (cluster *launcher) onRunning(ctx context.Context, unique string) { - w := wool.Get(ctx).In("launcher.deliver", wool.Field("service", unique)) - identity, ok := cluster.identity(unique) - if !ok || !cluster.delivers(identity) { - return - } - cluster.mu.Lock() - cluster.running[unique] = true - cluster.mu.Unlock() - waiter := cluster.waitFor(unique) +// runReadyDelivery publishes the latest readiness after an attempt. Failed +// restarts remove stale routes through the same nonterminal signed manifest. +func (cluster *launcher) runReadyDelivery(ctx context.Context, unique string, identity gitops.NativeIdentity, waiter *deliveryWaiter, failed bool) { defer cluster.doneWaiting(unique, waiter) + w := wool.Get(ctx).In("launcher.deliver", wool.Field("service", unique)) err := hostDelivery{ schedule: cluster.schedule, stop: cluster.stop, superseded: waiter.superseded, log: w.With(wool.Field("module", identity.Module)), ready: func(ctx context.Context) error { - // A refreshed composition can withdraw the workload while its - // delivery waits; nothing is then delivered on its behalf. if _, composed := cluster.identity(unique); !composed { return errWorkloadGone } return cluster.hostReady(ctx) }, - deliver: func(ctx context.Context) error { return cluster.deliver(ctx, identity.Module) }, + deliver: func(ctx context.Context) error { return cluster.deliverReadiness(ctx, identity.Module, failed) }, }.run(ctx) if deliveryAbandoned(err) { w.Debug("delivery abandoned", wool.ErrField(err)) - return - } - if err != nil { + } else if err != nil { 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)) } } -// newDeliverer is the deliverer of this run's manifests to the host: the -// host's delivery endpoint as the flow resolves it now, this run's signer and -// state, and the delivery account's credential. It also answers the gateway's -// current address, which the envelope follows. +// newDeliverer is the deliverer of this run's manifests to the host, with +// this run's signer and state and the delivery account's credential. A host +// whose delivery endpoint declares codefly.dev/composition receives them +// through it, over the run's one connection to it; the gateway address is then +// empty, since such a host takes no envelope. A host whose endpoint declares +// no such interface receives them through the legacy HTTP delivery route, with +// the envelope written through its gateway, whose address is answered: the +// reason is logged on every delivery that takes it. A host whose module cannot +// be read is neither: the delivery fails with the reason. 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) + implements, err := gitops.HostImplements(ctx, cluster.composed(), cluster.hostModule, cluster.hostService, cluster.hostEndpoint, gitops.CompositionInterface) if err != nil { - return nil, "", fmt.Errorf("the host's delivery endpoint is unresolved: %w", err) + return nil, "", 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())) + deliverer := &gitops.NativeDeliverer{ + Workspace: cluster.composed(), Env: cluster.env, Signer: cluster.signer, + StateDir: filepath.Join(cluster.stateDir, "delivery"), + Token: func(module string) (string, error) { + return cluster.vouch.Issue(cluster.vouch.Workload(module, workloadidentity.DeliveryService)) + }, + } + if implements { + target, resolveErr := cluster.flow.GRPCEndpointTarget(ctx, cluster.hostModule, cluster.hostService, cluster.hostEndpoint) + if resolveErr != nil { + return nil, "", fmt.Errorf("the host's delivery endpoint is unresolved: %w", resolveErr) + } + composition, dialErr := cluster.host.delivery(target, cluster.newCompositionDelivery) if dialErr != nil { - return nil, "", fmt.Errorf("dial the host's composition interface: %w", dialErr) + return nil, "", 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 + deliverer.Prepare, deliverer.Applied = composition.Prepare, composition.Applied + return deliverer, "", nil + } + wool.Get(ctx).In("launcher.deliver").Info(fmt.Sprintf("the host's delivery endpoint %s/%s/%s declares no %s, so manifests go to its legacy HTTP delivery route and the module envelope is written through host.gateway", + cluster.hostModule, cluster.hostService, cluster.hostEndpoint, gitops.CompositionInterface)) + if deliverer.DeliveryURL, err = cluster.flow.GetAddressForEndpoint(ctx, cluster.hostModule, cluster.hostService, cluster.hostEndpoint); err != nil { + return nil, "", fmt.Errorf("the host's delivery endpoint is unresolved: %w", err) } 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) + return nil, "", fmt.Errorf("the legacy HTTP delivery writes the envelope through the host gateway, and host.gateway %q names no /", cluster.env.Host.Gateway) } gatewayURL, err := cluster.flow.GetAddressForEndpoint(ctx, gatewayModule, gatewayService, "rest") if err != nil { return nil, "", fmt.Errorf("the host gateway's rest 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, - Token: func(module string) (string, error) { - return cluster.vouch.Issue(cluster.vouch.Workload(module, workloadidentity.DeliveryService)) + deliverer.ObserverToken = func() (string, error) { return cluster.vouch.Issue(cluster.vouch.Observer()) } + return deliverer, gatewayURL, nil +} + +// newCompositionDelivery speaks the composition interface over conn, reading +// each module's method policies, scope ceilings and roles from the module as +// the run composes it at the time of the delivery. +func (cluster *launcher) newCompositionDelivery(conn grpc.ClientConnInterface) *gitops.CompositionDelivery { + load := func(ctx context.Context, module string) (string, error) { + m, err := cluster.composed().LoadModuleFromName(ctx, module) + if err != nil { + return "", err + } + return m.Dir(), nil + } + return gitops.NewCompositionDelivery(conn, cluster.signer, + func(ctx context.Context, module string) ([]*compositionv1.MethodPolicyV1, bool, error) { + dir, err := load(ctx, module) + if err != nil { + return nil, false, err + } + return gitops.MethodPolicies(dir) }, - ObserverToken: func() (string, error) { - return cluster.vouch.Issue(cluster.vouch.Observer()) + func(ctx context.Context, module string) ([]*authorityv1.WorkScopeV1, error) { + dir, err := load(ctx, module) + if err != nil { + return nil, err + } + return gitops.ScopeCeilings(dir) }, - }, gatewayURL, nil + func(ctx context.Context, module string) ([]*compositionv1.RoleV1, error) { + dir, err := load(ctx, module) + if err != nil { + return nil, err + } + return gitops.Roles(dir) + }) +} + +// compositionHost holds the run's connection to the host's composition +// interface: dialled on the first delivery, reused by every later one and +// every retry, dialled again only when the host answers at another address (a +// host restarted on another port), and closed with the run. +type compositionHost struct { + mu sync.Mutex + target orchestration.GRPCTarget + conn *grpc.ClientConn + composition *gitops.CompositionDelivery + closed bool +} + +// delivery answers the composition client for target, dialling it unless the +// connection held already reaches it. +func (host *compositionHost) delivery(target orchestration.GRPCTarget, speak func(grpc.ClientConnInterface) *gitops.CompositionDelivery) (*gitops.CompositionDelivery, error) { + host.mu.Lock() + defer host.mu.Unlock() + if host.closed { + return nil, errRunStopped + } + if host.conn != nil && host.target == target { + return host.composition, nil + } + conn, err := target.Dial() + if err != nil { + return nil, fmt.Errorf("dial the host's composition interface at %s: %w", target.Address, err) + } + if host.conn != nil { + _ = host.conn.Close() + } + host.target, host.conn, host.composition = target, conn, speak(conn) + return host.composition, nil +} + +func (host *compositionHost) close() { + host.mu.Lock() + defer host.mu.Unlock() + host.closed = true + if host.conn != nil { + _ = host.conn.Close() + host.conn, host.composition = nil, nil + } } // withdraw takes the stopped workloads out of their modules' manifests: a // module that still runs an identity workload is delivered again at the next // generation, listing what remains; one that runs none is withdrawn — its -// manifest at the next generation with `removed: true`, terminal for the -// binding — and the run waits until the host has applied it. Each stopped -// workload is forgotten. The run's authority, the envelope and the host's -// process are untouched. Returned are the modules withdrawn. +// manifest at the next generation with `removed: true`, which the host keeps +// terminal for the binding while it holds it — and the run waits until the +// host has applied it. Each stopped workload is forgotten. The run's authority +// and the host's process are untouched. Returned are the modules withdrawn. func (cluster *launcher) withdraw(ctx context.Context, uniques []string) ([]string, error) { modules := map[string]bool{} for _, unique := range uniques { @@ -638,7 +706,7 @@ func (cluster *launcher) withdraw(ctx context.Context, uniques []string) ([]stri sort.Strings(pending) var withdrawn []string for _, module := range pending { - if len(cluster.startedIdentities(module)) > 0 { + if len(cluster.declaredIdentities(module)) > 0 { if err := cluster.deliver(ctx, module); err != nil { return withdrawn, fmt.Errorf("%s: deliver the workloads that remain: %w", module, err) } @@ -650,6 +718,10 @@ func (cluster *launcher) withdraw(ctx context.Context, uniques []string) ([]stri } if done { withdrawn = append(withdrawn, module) + // A module that left the composition keeps no staged frontend. + if err := cluster.forgetFrontend(ctx, module); err != nil { + wool.Get(ctx).In("launcher.withdraw").Warn("cannot remove the staged frontend of a withdrawn module", wool.Field("module", module), wool.ErrField(err)) + } } } return withdrawn, nil @@ -682,14 +754,20 @@ func (cluster *launcher) withdrawModule(ctx context.Context, module string) (boo return false, err } receipt, err := deliverer.Deliver(ctx, withdrawal) - if err != nil { - return false, err - } - cluster.delivered[module] = withdrawal - wool.Get(ctx).In("launcher.withdraw").Info("withdrawal received", wool.Field("module", module), - wool.Field("disposition", receipt.Disposition), wool.Field("generation", receipt.Generation)) - if err := deliverer.WaitForWithdrawal(ctx, withdrawal); err != nil { + switch { + case errors.Is(err, solutionhost.ErrWithdrawn): + // The host already holds a withdrawal of this binding (a module + // re-added and refused, then dropped again): nothing is served under it. + cluster.delivered[module] = withdrawal + case err != nil: return false, err + default: + cluster.delivered[module] = withdrawal + wool.Get(ctx).In("launcher.withdraw").Info("withdrawal received", wool.Field("module", module), + wool.Field("disposition", receipt.Disposition), wool.Field("generation", receipt.Generation)) + if err := deliverer.WaitForWithdrawal(ctx, withdrawal); err != nil { + return false, err + } } cluster.mu.Lock() delete(cluster.pendingWithdrawals, module) @@ -742,64 +820,121 @@ func (cluster *launcher) startedIdentities(module string) []gitops.NativeIdentit } // deliver delivers a module's manifest, listing every identity workload of it -// that runs now, with the addresses this run allocated for its routed -// endpoints, after making sure the host holds the composition's envelope. +// declared in the run, with the addresses this run allocated for the routed +// endpoints of those that are ready. func (cluster *launcher) deliver(ctx context.Context, module string) error { - if err := cluster.acquireDelivery(ctx); err != nil { + return cluster.deliverReadiness(ctx, module, false) +} + +// deliverReadiness delivers a module's readiness. A ready delivery builds its +// frontend bundle first, before the delivery lock is taken, so a build delays +// no other module's delivery; an early one (after a failed start) builds +// nothing. +func (cluster *launcher) deliverReadiness(ctx context.Context, module string, early bool) error { + if len(cluster.declaredIdentities(module)) == 0 { + return nil + } + loaded, err := cluster.composed().LoadModuleFromName(ctx, module) + if err != nil { + return err + } + var bundle *solutionhost.FrontendPayload + if !early { + if bundle, err = cluster.frontendBundle(ctx, loaded); err != nil { + return err + } + } + if err = cluster.acquireDelivery(ctx); err != nil { return err } defer cluster.delivery.Release(1) - started := cluster.startedIdentities(module) - if len(started) == 0 { + // Failure may precede admission. Replace a previous host snapshot, but do + // not admit a new module just to clean up an unstarted attempt. + if early && cluster.delivered[module] == nil { return nil } - loaded, err := cluster.composed().LoadModuleFromName(ctx, module) + if len(cluster.declaredIdentities(module)) == 0 { + return nil + } + if err = cluster.checkRoutingSnapshot(ctx); err != nil { + return err + } + deliverer, gatewayURL, err := cluster.newDeliverer(ctx) if err != nil { return err } + manifest, err := cluster.renderDelivery(ctx, deliverer, loaded, early, bundle) + if err != nil { + return err + } + return cluster.applyDelivery(ctx, deliverer, gatewayURL, module, manifest) +} + +// renderDelivery renders a loaded module's manifest. An early delivery keeps +// the page payload previously published, since deferral removes a page; a +// ready one names the bundle its caller built. +func (cluster *launcher) renderDelivery(ctx context.Context, deliverer *gitops.NativeDeliverer, loaded *resources.Module, early bool, bundle *solutionhost.FrontendPayload) (*solutionhost.ModuleManifest, error) { + module := loaded.Name services := make([]*resources.Service, 0, len(loaded.ServiceReferences)) for _, reference := range loaded.ServiceReferences { service, loadErr := cluster.composed().LoadService(ctx, &resources.ServiceWithModule{Name: reference.Name, Module: module}) if loadErr != nil { - return loadErr + return nil, loadErr } services = append(services, service) } - deliverer, gatewayURL, err := cluster.newDeliverer(ctx) - if 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 + if early { + // Deferral removes a page. Keep a previously published payload during + // unrelated partial startup instead of accidentally unpublishing it. + if previous := cluster.delivered[module]; previous != nil && previous.Frontend != nil { + bundle = previous.Frontend.Payload } } - bundle, err := cluster.frontendBundle(ctx, loaded) - if err != nil { - return err + started := cluster.startedIdentities(module) + var ready []string + for _, identity := range started { + ready = append(ready, resources.ServiceUnique(identity.Module, identity.Service)) } - carriers, err := cluster.flow.EndpointCarriers(ctx, module, resources.NewNativeNetworkAccess()) + carriers, err := cluster.flow.ReadyEndpointCarriers(ctx, module, resources.NewNativeNetworkAccess(), ready) if err != nil { - return err + return nil, err } - manifest, err := deliverer.Render(ctx, &gitops.NativeWorkload{Module: loaded, Services: services, Started: started, Carriers: carriers, FrontendBundle: bundle}) - if err != nil { - return err + return deliverer.Render(ctx, &gitops.NativeWorkload{ + Module: loaded, Services: services, Declared: cluster.declaredIdentities(module), + Started: started, Carriers: carriers, FrontendBundle: bundle, DeferFrontend: early && bundle == nil, + }) +} + +// Caller holds delivery across rendering, generation reservation and Applied. +func (cluster *launcher) applyDelivery(ctx context.Context, deliverer *gitops.NativeDeliverer, gatewayURL, module string, manifest *solutionhost.ModuleManifest) error { + if gatewayURL != "" { + if err := cluster.ensureEnvelope(ctx, gatewayURL); err != nil { + return err + } } receipt, err := deliverer.Deliver(ctx, manifest) if err != nil { return err } cluster.delivered[module] = manifest - wool.Get(ctx).In("launcher.deliver").Info("delivered", wool.Field("module", module), wool.Field("workloads", len(manifest.Workloads)), + w := wool.Get(ctx).In("launcher.deliver") + w.Info("delivered", wool.Field("module", manifest.Alias), wool.Field("workloads", len(manifest.Workloads)), wool.Field("disposition", receipt.Disposition), wool.Field("generation", receipt.Generation)) - // Accepted into the inbox is not applied. The host says which, and why. if err := deliverer.WaitForApplied(ctx, manifest); err != nil { - return fmt.Errorf("module %s is delivered and not served: %w", module, err) + return fmt.Errorf("module %s is delivered and not served: %w", manifest.Alias, err) + } + w.Info("applied", wool.Field("module", manifest.Alias), wool.Field("generation", manifest.Generation)) + return nil +} + +// checkRoutingSnapshot refuses a delivery whose policy tables a hop's frozen +// routes no longer describe: the tables are read live, the routes were fixed +// when the run started, and a contract published since would deliver new +// policies behind old routes. Called under the delivery lock, before reading. +func (cluster *launcher) checkRoutingSnapshot(ctx context.Context) error { + if err := cluster.flow.CheckRoutingSnapshot(ctx, cluster.composed()); err != nil { + return fmt.Errorf("deliver no policy table behind the hop's routes: %w", err) } - wool.Get(ctx).In("launcher.deliver").Info("applied", wool.Field("module", module), wool.Field("generation", manifest.Generation)) return nil } diff --git a/cmd/run/launcher_admission.go b/cmd/run/launcher_admission.go new file mode 100644 index 000000000..072e4271e --- /dev/null +++ b/cmd/run/launcher_admission.go @@ -0,0 +1,132 @@ +package run + +import ( + "context" + "fmt" + "sort" + + "github.com/codefly-dev/cli/pkg/gitops" + "github.com/codefly-dev/cli/pkg/orchestration" + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/solutionhost" + "github.com/codefly-dev/core/wool" +) + +func (cluster *launcher) declaredIdentities(module string) []gitops.NativeIdentity { + var declared []gitops.NativeIdentity + for _, unique := range cluster.flow.LocalRuntimeServices() { + if identity, ok := cluster.identity(unique); ok && identity.Module == module { + declared = append(declared, identity) + } + } + sort.Slice(declared, func(i, j int) bool { return declared[i].Service < declared[j].Service }) + return declared +} + +func (cluster *launcher) beginRuntimeStart(ctx context.Context, service *resources.Service) (*orchestration.RuntimeStartAttempt, error) { + unique, identity, ok := cluster.identityOf(service) + if !ok { + return nil, nil + } + cluster.mu.Lock() + delete(cluster.running, unique) + waiter := cluster.waitFor(unique) + var projectErr error + if cluster.vouch != nil { + _, projectErr = cluster.project(unique, identity) + } + cluster.mu.Unlock() + finish := func(started bool) { + cluster.mu.Lock() + cluster.waitersMu.Lock() + current := cluster.waiters[unique] == waiter + if current { + if started { + cluster.running[unique] = true + } else { + delete(cluster.running, unique) + if cluster.vouch != nil { + cluster.vouch.Forget(unique) + } + } + } + cluster.waitersMu.Unlock() + cluster.mu.Unlock() + if current && cluster.delivers(identity) { + publishCtx := ctx + if !started { + publishCtx = context.WithoutCancel(ctx) + } + go cluster.runReadyDelivery(publishCtx, unique, identity, waiter, !started) + } else { + cluster.doneWaiting(unique, waiter) + } + } + attempt := &orchestration.RuntimeStartAttempt{Complete: finish} + if cluster.delivers(identity) { + attempt.Admit = func(ctx context.Context) error { + return hostDelivery{ + schedule: cluster.schedule, stop: cluster.stop, superseded: waiter.superseded, + ready: cluster.hostReady, deliver: cluster.admitSelected, + log: wool.Get(ctx).In("launcher.admit", wool.Field("service", unique)), + }.run(ctx) + } + } + return attempt, projectErr +} + +// Admit the whole selected authority closure before the first consumer starts. +// A binding can target a later process, so declarations precede readiness. +// Failed Start leaves an admitted but unready workload, never a terminal +// withdrawal; normal run teardown stops credential refresh. +func (cluster *launcher) admitSelected(ctx context.Context) error { + if err := cluster.acquireDelivery(ctx); err != nil { + return err + } + defer cluster.delivery.Release(1) + modules := map[string]bool{} + for _, unique := range cluster.flow.LocalRuntimeServices() { + if identity, ok := cluster.identity(unique); ok && cluster.delivers(identity) { + modules[identity.Module] = true + } + } + names := make([]string, 0, len(modules)) + for module := range modules { + names = append(names, module) + } + sort.Strings(names) + if err := cluster.checkRoutingSnapshot(ctx); err != nil { + return err + } + deliverer, gatewayURL, err := cluster.newDeliverer(ctx) + if err != nil { + return err + } + manifests := make([]*solutionhost.ModuleManifest, 0, len(names)) + for _, module := range names { + loaded, err := cluster.composed().LoadModuleFromName(ctx, module) + if err != nil { + return err + } + // Admission builds no page: the bundle a ready delivery builds, outside + // the delivery lock, publishes it. + manifest, err := cluster.renderDelivery(ctx, deliverer, loaded, true, nil) + if err != nil { + return err + } + if manifest.Authority != nil { + for _, binding := range manifest.Authority.Bindings { + if !modules[binding.Audience] { + return fmt.Errorf("module %s binding %s targets %s, which has no selected local identity workload; authority admission requires its target declaration", module, binding.ID, binding.Audience) + } + } + } + manifests = append(manifests, manifest) + } + for n, manifest := range manifests { + if err := cluster.applyDelivery(ctx, deliverer, gatewayURL, names[n], manifest); err != nil { + return err + } + } + return nil +} diff --git a/cmd/run/launcher_admission_test.go b/cmd/run/launcher_admission_test.go new file mode 100644 index 000000000..0063e44f9 --- /dev/null +++ b/cmd/run/launcher_admission_test.go @@ -0,0 +1,407 @@ +package run + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "fmt" + "net" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + agentservices "github.com/codefly-dev/core/agents/services" + runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0" + coreservices "github.com/codefly-dev/core/services" + "google.golang.org/grpc/credentials/insecure" + + 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/orchestration" + "github.com/codefly-dev/cli/pkg/run/identity" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type admissionFlow struct { + pairedLauncherFlow + selected []string +} + +func (f *admissionFlow) LocalRuntimeServices() []string { return f.selected } +func (f *admissionFlow) ReadyEndpointCarriers(_ context.Context, module string, _ *basev0.NetworkAccess, ready []string) (map[string]string, error) { + carriers := map[string]string{} + for _, unique := range ready { + ref, _ := resources.ParseServiceWithOptionalModule(unique) + key := resources.EndpointAsEnvironmentVariableKey(&resources.EndpointInformation{Module: module, Service: ref.Name, Name: "http", API: "http"}) + carriers[key] = "http://" + f.address + } + return carriers, nil +} + +type admissionPeer struct { + compositionv1.UnimplementedCompositionServiceServer + mu sync.Mutex + manifests []*solutionhost.ModuleManifest +} + +func (p *admissionPeer) ApplyManifest(_ context.Context, req *compositionv1.ApplyManifestRequest) (*compositionv1.ApplyManifestResponse, error) { + m, err := solutionhost.ManifestFromVerified(req.GetCarrier().GetDocument()) + if err != nil { + return nil, err + } + p.mu.Lock() + p.manifests = append(p.manifests, m) + p.mu.Unlock() + return &compositionv1.ApplyManifestResponse{Generation: m.Generation}, nil +} + +func (p *admissionPeer) snapshots() []*solutionhost.ModuleManifest { + p.mu.Lock() + defer p.mu.Unlock() + return append([]*solutionhost.ModuleManifest(nil), p.manifests...) +} + +func admissionLauncher(t *testing.T) (*launcher, *admissionPeer) { + t.Helper() + 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 = composeModule(t, workspace, "alpha") + workspace = composeModule(t, workspace, "beta") + for _, name := range []string{"alpha", "beta"} { + dir := filepath.Join(workspace.Dir(), "modules", name) + require.NoError(t, os.WriteFile(filepath.Join(dir, "module.package.codefly.yaml"), []byte("kind: module-package\nschema: codefly/module-package/v2\nid: example/"+name+"\nversion: 1.0.0\nminimum-codefly-version: '>=0.0.0'\nartifact-roots: [services]\ncontracts:\n composition: '>=1.0'\n"), 0600)) + path := filepath.Join(dir, "services", "api", resources.ServiceConfigurationName) + body, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(body, []byte("endpoints:\n - name: http\n api: http\n visibility: internal\n")...), 0600)) + } + var err error + 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 := &admissionPeer{} + 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) + identities, err := gitops.ResolveNativeIdentities(t.Context(), workspace, env) + require.NoError(t, err) + flow := &admissionFlow{pairedLauncherFlow: pairedLauncherFlow{fakeLauncherFlow: fakeLauncherFlow{ready: func(string) bool { return true }}, address: listener.Addr().String()}, selected: []string{"model/model", "alpha/api", "beta/api"}} + cluster := &launcher{workspace: workspace, env: env, signer: signer, vouch: vouch, identities: identities, stateDir: t.TempDir(), hostModule: "model", hostService: "model", hostEndpoint: "rest", flow: flow, running: map[string]bool{}, delivered: map[string]*solutionhost.ModuleManifest{}, schedule: testDeliverySchedule, stop: make(chan struct{})} + cluster.declare(identities) + t.Cleanup(cluster.close) + return cluster, peer +} + +// Exercise the same begin/admit/complete contract that Runner.Start owns. +func admitRuntimeAttempt(t *testing.T, cluster *launcher, service *resources.Service) (func(bool), error) { + t.Helper() + attempt, err := cluster.beginRuntimeStart(t.Context(), service) + if err != nil { + return attempt.Complete, err + } + if attempt.Admit != nil { + err = attempt.Admit(t.Context()) + } + return attempt.Complete, err +} + +func TestAdmissionPrecedesStartAndFailureDoesNotWithdraw(t *testing.T) { + cluster, peer := admissionLauncher(t) + service, err := cluster.composed().LoadService(t.Context(), &resources.ServiceWithModule{Module: "alpha", Name: "api"}) + require.NoError(t, err) + finish, err := admitRuntimeAttempt(t, cluster, service) + require.NoError(t, err) + snapshots := peer.snapshots() + require.Len(t, snapshots, 2, "both selected audiences must be applied before Start") + for _, m := range snapshots { + require.NotEmpty(t, m.Workloads) + require.Empty(t, m.Endpoints) + require.False(t, m.Removed) + } + finish(false) + _, projected := cluster.vouch.Projected("alpha/api") + require.False(t, projected) + require.Len(t, cluster.declaredIdentities("alpha"), 1, "failed Start retains selected membership") + finish, err = admitRuntimeAttempt(t, cluster, service) + require.NoError(t, err) + _, projected = cluster.vouch.Projected("alpha/api") + require.True(t, projected) + for _, m := range peer.snapshots() { + require.False(t, m.Removed) + require.EqualValues(t, 1, m.Generation) + } + finish(true) + require.Eventually(t, func() bool { + for _, m := range peer.snapshots() { + if m.Alias == "alpha" && len(m.Endpoints) > 0 { + return true + } + } + return false + }, time.Second, time.Millisecond) +} + +func TestStaleStartCompletionCannotChangeNewAttempt(t *testing.T) { + cluster, peer := admissionLauncher(t) + service, err := cluster.composed().LoadService(t.Context(), &resources.ServiceWithModule{Module: "alpha", Name: "api"}) + require.NoError(t, err) + old, err := admitRuntimeAttempt(t, cluster, service) + require.NoError(t, err) + current, err := admitRuntimeAttempt(t, cluster, service) + require.NoError(t, err) + old(false) + _, projected := cluster.vouch.Projected("alpha/api") + require.True(t, projected) + old(true) + cluster.mu.Lock() + ready := cluster.running["alpha/api"] + cluster.mu.Unlock() + require.False(t, ready) + for _, m := range peer.snapshots() { + require.Empty(t, m.Endpoints) + } + current(false) +} + +func TestNativeAuthorityOrdersHostWithoutAuthoredDependency(t *testing.T) { + cluster, _ := admissionLauncher(t) + workspace := cluster.composed() + module, err := workspace.LoadModuleFromName(t.Context(), "alpha") + require.NoError(t, err) + service, err := module.LoadServiceFromName(t.Context(), "api") + require.NoError(t, err) + flow, err := orchestration.NewFlow(t.Context(), workspace, module, service, cluster.env, orchestration.RunMode, + orchestration.WithRunModuleClosure("alpha"), NativeAuthorityPrerequisites(cluster.env, resources.RuntimeContextNative)) + require.NoError(t, err) + run, err := flow.World().Dependencies.ForStage(resources.StageRun) + require.NoError(t, err) + order, err := run.OrderTo(t.Context(), "alpha/api") + require.NoError(t, err) + require.Len(t, order, 1) + require.Equal(t, "model/model", order[0].Unique) + build, err := flow.World().Dependencies.ForStage(resources.StageBuild) + require.NoError(t, err) + order, err = build.OrderTo(t.Context(), "alpha/api") + require.NoError(t, err) + require.Empty(t, order) +} + +func TestAdmissionRefusesUndeliveredBindingBeforeSending(t *testing.T) { + for _, audience := range []string{"missing", "model"} { + t.Run(audience, func(t *testing.T) { + cluster, peer := admissionLauncher(t) + contract := "schema: codefly/module-contract/v1\nprincipal: alpha\nnamespaces: []\nqueues: []\ndestinations: []\nscope_ceilings: []\nbindings:\n - id: read-target\n operations: [invoke]\n audience: {from: target/read-audience}\n scope_ceiling:\n invoke:\n - resource_kind: target.item\n actions: [read]\n" + configDir := filepath.Join(cluster.composed().Dir(), "configurations", "local") + require.NoError(t, os.MkdirAll(configDir, 0700)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "target.env"), []byte("READ_AUDIENCE="+audience+"\n"), 0600)) + path := filepath.Join(cluster.composed().Dir(), "modules", "alpha", "module.contract.codefly.yaml") + require.NoError(t, os.WriteFile(path, []byte(contract), 0600)) + err := cluster.admitSelected(t.Context()) + require.ErrorContains(t, err, "binding read-target targets "+audience) + require.Empty(t, peer.snapshots(), "validate the entire selection before applying any manifest") + }) + } +} + +func TestFailedPreparationRemovesPublishedRoutesAndKeepsSibling(t *testing.T) { + for _, overlap := range []bool{false, true} { + t.Run(fmt.Sprint(overlap), func(t *testing.T) { + cluster, peer := admissionLauncher(t) + load := func(name string) *resources.Service { + service, err := cluster.composed().LoadService(t.Context(), &resources.ServiceWithModule{Module: name, Name: "api"}) + require.NoError(t, err) + return service + } + for _, name := range []string{"alpha", "beta"} { + finish, err := admitRuntimeAttempt(t, cluster, load(name)) + require.NoError(t, err) + finish(true) + } + latest := func() map[string]*solutionhost.ModuleManifest { + out := map[string]*solutionhost.ModuleManifest{} + for _, m := range peer.snapshots() { + out[m.Alias] = m + } + return out + } + require.Eventually(t, func() bool { + m := latest() + return m["alpha"] != nil && m["beta"] != nil && len(m["alpha"].Endpoints) > 0 && len(m["beta"].Endpoints) > 0 + }, time.Second, time.Millisecond) + ctx, cancel := context.WithCancel(t.Context()) + attempt, err := cluster.beginRuntimeStart(ctx, load("alpha")) + require.NoError(t, err) + if overlap { + old := attempt + attempt, err = cluster.beginRuntimeStart(ctx, load("alpha")) + require.NoError(t, err) + old.Complete(false) + } + cancel() // Preparation failed before Admit. Cleanup still has to replace routes. + attempt.Complete(false) + require.Eventually(t, func() bool { + m := latest() + return len(m["alpha"].Endpoints) == 0 && len(m["beta"].Endpoints) > 0 + }, time.Second, time.Millisecond) + _, projected := cluster.vouch.Projected("alpha/api") + require.False(t, projected) + _, projected = cluster.vouch.Projected("beta/api") + require.True(t, projected) + for _, m := range latest() { + require.False(t, m.Removed) + require.Len(t, m.Workloads, 1) + } + + }) + } +} + +func TestRuntimeOverridesDoNotIssueOrRestoreCredentials(t *testing.T) { + cluster, _ := admissionLauncher(t) + service, err := cluster.composed().LoadService(t.Context(), &resources.ServiceWithModule{Module: "alpha", Name: "api"}) + require.NoError(t, err) + path := cluster.runtimeOverrides(service)[workloadTokenFileVariable] + require.NotEmpty(t, path) + require.NoFileExists(t, path) + _, projected := cluster.vouch.Projected("alpha/api") + require.False(t, projected) + attempt, err := cluster.beginRuntimeStart(t.Context(), service) + require.NoError(t, err) + require.FileExists(t, path) + attempt.Complete(false) + require.Equal(t, path, cluster.runtimeOverrides(service)[workloadTokenFileVariable]) + _, projected = cluster.vouch.Projected("alpha/api") + require.False(t, projected, "late environment preparation cannot restore refresh membership") +} + +// A real Runner and the real launcher share a World here. The controlled peer +// observes signed delivery before the actual Runtime.Start RPC. +type admittedRuntimePeer struct { + runtimev0.UnimplementedRuntimeServer + composition *admissionPeer + starts, stops atomic.Int32 +} + +func (p *admittedRuntimePeer) Load(context.Context, *runtimev0.LoadRequest) (*runtimev0.LoadResponse, error) { + return &runtimev0.LoadResponse{Status: &runtimev0.LoadStatus{State: runtimev0.LoadStatus_READY}}, nil +} +func (p *admittedRuntimePeer) Init(context.Context, *runtimev0.InitRequest) (*runtimev0.InitResponse, error) { + return &runtimev0.InitResponse{Status: &runtimev0.InitStatus{State: runtimev0.InitStatus_READY}}, nil +} +func (p *admittedRuntimePeer) Start(_ context.Context, req *runtimev0.StartRequest) (*runtimev0.StartResponse, error) { + if len(p.composition.snapshots()) < 2 { + return nil, fmt.Errorf("Start preceded selected declarations") + } + if _, err := os.Stat(req.GetOverrides()[workloadTokenFileVariable]); err != nil { + return nil, err + } + p.starts.Add(1) + return &runtimev0.StartResponse{Status: &runtimev0.StartStatus{State: runtimev0.StartStatus_STARTED}}, nil +} +func (p *admittedRuntimePeer) Stop(context.Context, *runtimev0.StopRequest) (*runtimev0.StopResponse, error) { + p.stops.Add(1) + return &runtimev0.StopResponse{Status: &runtimev0.StopStatus{State: runtimev0.StopStatus_SUCCESS}}, nil +} + +func TestRunnerFailureReplacesLauncherRoutes(t *testing.T) { + cluster, composition := admissionLauncher(t) + workspace := cluster.composed() + module, err := workspace.LoadModuleFromName(t.Context(), "alpha") + require.NoError(t, err) + service, err := module.LoadServiceFromName(t.Context(), "api") + require.NoError(t, err) + flow, err := orchestration.NewFlow(t.Context(), workspace, module, service, cluster.env, orchestration.RunMode) + require.NoError(t, err) + flow.WithTemporaryPorts(true) + world := flow.World() + world.BeginRuntimeStart, world.RuntimeOverrides = cluster.beginRuntimeStart, cluster.runtimeOverrides + world.BeforeRuntimeStop = cluster.close + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + peer := &admittedRuntimePeer{composition: composition} + server := grpc.NewServer() + runtimev0.RegisterRuntimeServer(server, peer) + 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() }) + id, err := service.Identity() + require.NoError(t, err) + id.Workspace = workspace.Name + instance := &coreservices.Instance{Workspace: workspace, Module: module, Service: service, Identity: id} + instance.Runtime = &coreservices.RuntimeInstance{Instance: instance, Runtime: agentservices.NewRuntimeAgentClient(conn)} + runner, err := orchestration.NewRunner(t.Context(), instance, world) + require.NoError(t, err) + runner.WithRuntimeContext(resources.RuntimeContextNative) + _, err = runner.Load(t.Context()) + require.NoError(t, err) + _, err = runner.Init(t.Context()) + require.NoError(t, err) + _, projected := cluster.vouch.Projected("alpha/api") + require.False(t, projected, "Init carries only a path") + _, err = runner.Start(t.Context()) + require.NoError(t, err) + latestReady := func() bool { + ready := false + for _, m := range composition.snapshots() { + if m.Alias == "alpha" { + ready = len(m.Endpoints) > 0 + } + } + return ready + } + require.Eventually(t, latestReady, time.Second, time.Millisecond) + runner.WithOutputEnv(t.TempDir()) + _, err = runner.Start(t.Context()) + require.Error(t, err, "request preparation fails after stopping the previous process") + require.EqualValues(t, 1, peer.starts.Load()) + require.EqualValues(t, 1, peer.stops.Load()) + require.Eventually(t, func() bool { return !latestReady() }, time.Second, time.Millisecond) + _, projected = cluster.vouch.Projected("alpha/api") + require.False(t, projected) + for _, m := range composition.snapshots() { + require.False(t, m.Removed) + } + require.NoError(t, flow.Stop()) + select { + case <-cluster.stop: + default: + t.Fatal("Flow.Stop did not stop launcher") + } +} + +// A contract published after the hop's routes were frozen is never delivered +// behind them: startup admission and readiness delivery re-check the routing +// snapshot under the delivery lock and send nothing when it moved. +func TestAdmissionAndDeliveryRefuseAMovedRoutingSnapshot(t *testing.T) { + cluster, peer := admissionLauncher(t) + flow, ok := cluster.flow.(*admissionFlow) + require.True(t, ok) + flow.routing = fmt.Errorf("published hop routes changed: stop and restart this Codefly run to adopt them") + service, err := cluster.composed().LoadService(t.Context(), &resources.ServiceWithModule{Module: "alpha", Name: "api"}) + require.NoError(t, err) + _, err = admitRuntimeAttempt(t, cluster, service) + require.ErrorContains(t, err, "deliver no policy table behind the hop's routes") + require.ErrorContains(t, err, "stop and restart") + cluster.delivered["alpha"] = &solutionhost.ModuleManifest{} + cluster.running["alpha/api"] = true + require.ErrorContains(t, cluster.deliverReadiness(t.Context(), "alpha", true), "stop and restart") + require.Empty(t, peer.snapshots(), "nothing is sent behind moved routes") +} diff --git a/cmd/run/native_delivery_cancellation_test.go b/cmd/run/native_delivery_cancellation_test.go index 617af2d52..6f9b1a539 100644 --- a/cmd/run/native_delivery_cancellation_test.go +++ b/cmd/run/native_delivery_cancellation_test.go @@ -159,7 +159,7 @@ func TestNativeDeliveryCancelsInFlightOperations(t *testing.T) { } } -// Capture the hook through the same registration used by attachLauncher. The +// Drive the completed-attempt delivery through its run cancellation. The // probe waits for cancellation, while service teardown has not even begun. type cancelableReadinessFlow struct { *fakeLauncherFlow diff --git a/cmd/run/native_delivery_schedule.go b/cmd/run/native_delivery_schedule.go index 0799f4bf2..3651b7b82 100644 --- a/cmd/run/native_delivery_schedule.go +++ b/cmd/run/native_delivery_schedule.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net" - "net/http" "net/url" "syscall" "time" @@ -32,7 +31,12 @@ type launcherFlow interface { World() *orchestration.World ServiceFromUnique(unique string) (*resources.Service, error) GetAddressForEndpoint(ctx context.Context, module, service, endpoint string) (string, error) + GRPCEndpointTarget(ctx context.Context, module, service, endpoint string) (orchestration.GRPCTarget, error) + ServiceRuntimeContext(unique string) string EndpointCarriers(ctx context.Context, module string, access *basev0.NetworkAccess) (map[string]string, error) + ReadyEndpointCarriers(ctx context.Context, module string, access *basev0.NetworkAccess, services []string) (map[string]string, error) + LocalRuntimeServices() []string + CheckRoutingSnapshot(ctx context.Context, workspace *resources.Workspace) error ServiceReadiness(ctx context.Context, service string) *orchestration.ReadinessFailure } @@ -63,26 +67,20 @@ func deliveryAbandoned(err error) bool { } // transientDeliveryRefusal reports whether a failed delivery attempt says the -// host is not serving yet rather than that it refuses: a 5xx (the envelope -// read's 503 among them); a 404 from the gateway, before it exposes the route -// (a 404 from the host's own delivery endpoint is a wrong endpoint, not a -// wait); a 409 on the envelope (a lost revision race); or no answer at all — -// refused, reset or timed out, never a request that could not be formed. -// Anything else — another 4xx, a policy refusal, a manifest the host refuses — -// is not, and fails at once. +// host is not serving yet rather than that it refuses: the host was +// unreachable, out of time or out of capacity (HostStatusError.Transient, which +// also classifies the legacy HTTP delivery's statuses), or the HTTP request got +// no answer at all. +// Anything else — an interface the host does not serve, a policy refusal, a +// manifest the host refuses, a refusal it could not classify — is its answer, +// and fails at once with its reason. func transientDeliveryRefusal(err error) bool { var refusal *gitops.HostStatusError if errors.As(err, &refusal) { - switch { - case refusal.Status >= http.StatusInternalServerError: - return true - case refusal.Status == http.StatusNotFound: - return refusal.Gateway - case refusal.Status == http.StatusConflict: - return refusal.Envelope - } - return false + return refusal.Transient() } + // The legacy HTTP delivery's no answer at all: refused, reset or timed + // out, never a request that could not be formed. var transport *url.Error if !errors.As(err, &transport) { return false @@ -206,7 +204,11 @@ func (d hostDelivery) run(parent context.Context) (result error) { // the gateway the envelope is written through, then the service whose // delivery endpoint receives the documents. func (cluster *launcher) hostReady(ctx context.Context) error { - for _, unique := range []string{cluster.env.Host.Gateway, cluster.hostModule + "/" + cluster.hostService} { + hosts, err := nativeHostServices(ctx, cluster.composed(), cluster.env) + if err != nil { + return err + } + for _, unique := range hosts { if unique == "" { continue } diff --git a/cmd/run/native_delivery_schedule_test.go b/cmd/run/native_delivery_schedule_test.go index 5c6ff6931..163263dc7 100644 --- a/cmd/run/native_delivery_schedule_test.go +++ b/cmd/run/native_delivery_schedule_test.go @@ -9,11 +9,15 @@ import ( "net" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "sync" "sync/atomic" "testing" "time" + 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/cli/pkg/gitops" "github.com/codefly-dev/cli/pkg/orchestration" @@ -22,6 +26,9 @@ import ( "github.com/codefly-dev/core/solutionhost" "github.com/codefly-dev/core/wool" "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) var testDeliverySchedule = deliverySchedule{Deadline: 2 * time.Second, FirstBackoff: time.Millisecond, MaxBackoff: 5 * time.Millisecond} @@ -62,9 +69,9 @@ func readEnvelope(gatewayURL string) func(context.Context) error { } } -// postManifest posts a module manifest straight to the host's delivery +// postManifestHTTP posts a module manifest straight to the host's delivery // endpoint, as a delivery does after the envelope. -func postManifest(t *testing.T, deliveryURL string) func(context.Context) error { +func postManifestHTTP(t *testing.T, deliveryURL string) func(context.Context) error { return func(ctx context.Context) error { _, private, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) @@ -82,14 +89,82 @@ func postManifest(t *testing.T, deliveryURL string) func(context.Context) error } } +// refusingComposition is a composition host that refuses the first refusals +// ApplyManifest calls with their codes, then accepts. +type refusingComposition struct { + compositionv1.UnimplementedCompositionServiceServer + mu sync.Mutex + refusals []codes.Code + calls int +} + +func (h *refusingComposition) ApplyManifest(_ context.Context, req *compositionv1.ApplyManifestRequest) (*compositionv1.ApplyManifestResponse, error) { + h.mu.Lock() + defer h.mu.Unlock() + h.calls++ + if len(h.refusals) > 0 { + code := h.refusals[0] + h.refusals = h.refusals[1:] + return nil, status.Errorf(code, "refused with %s", code) + } + manifest, err := solutionhost.ManifestFromVerified(req.GetCarrier().GetDocument()) + if err != nil { + return nil, err + } + return &compositionv1.ApplyManifestResponse{Principal: manifest.Alias, Generation: manifest.Generation}, nil +} + +// serveComposition serves host — nil serves no composition interface at all — +// on a loopback port, and answers how the run reaches it. +func serveComposition(t *testing.T, host compositionv1.CompositionServiceServer) orchestration.GRPCTarget { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + server := grpc.NewServer() + if host != nil { + compositionv1.RegisterCompositionServiceServer(server, host) + } + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + return orchestration.GRPCTarget{Address: listener.Addr().String()} +} + +// deliverTo delivers a module manifest through the composition interface at +// target, as a delivery attempt does. +func deliverTo(t *testing.T, target orchestration.GRPCTarget) func(context.Context) error { + return func(ctx context.Context) error { + _, private, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + signer, err := solutionhost.NewInMemorySigner("dev-test", private) + require.NoError(t, err) + conn, err := target.Dial() + require.NoError(t, err) + defer func() { _ = conn.Close() }() + composition := gitops.NewCompositionDelivery(conn, signer, nil, nil, nil) + deliverer := &gitops.NativeDeliverer{Signer: signer, StateDir: t.TempDir(), Prepare: composition.Prepare, Token: func(string) (string, error) { return "token", nil }} + _, err = deliverer.Deliver(ctx, &solutionhost.ModuleManifest{ + Schema: solutionhost.SchemaModuleManifestV1, Binding: "acme.local.model", Alias: "model", Kind: solutionhost.KindModule, + Host: solutionhost.HostTarget{Coordinate: "example/local/dev", Domain: "acme"}, + Release: solutionhost.Release{Publisher: "acme", Name: "model", Version: "0.1.0"}, + Workloads: []solutionhost.Workload{{Service: "model", Container: "model"}}, + Authority: &solutionhost.Authority{Principal: "model"}, + }) + return err + } +} + // Each class of refusal, produced by the real client against a host that // answers it once: the ones that say "not serving yet" are asked again, the // ones that say no are not. func TestANativeDeliveryRetriesExactlyTheRefusalsThatMeanNotServingYet(t *testing.T) { closed, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) + refused := orchestration.GRPCTarget{Address: closed.Addr().String()} refusedURL := "http://" + closed.Addr().String() require.NoError(t, closed.Close()) + refusing := func(code codes.Code) func(context.Context) error { + return deliverTo(t, serveComposition(t, &refusingComposition{refusals: []codes.Code{code}})) + } for _, class := range []struct { name string @@ -97,18 +172,27 @@ func TestANativeDeliveryRetriesExactlyTheRefusalsThatMeanNotServingYet(t *testin says string // the refusal really is the class it names retried bool }{ - {"a 503 from the gateway's envelope read", readEnvelope(answering(t, http.StatusServiceUnavailable, "JWT validation not configured")), "HTTP 503", true}, - {"a 404 from the gateway before it exposes the route", readEnvelope(answering(t, http.StatusNotFound, "endpoint not exposed")), "404 Not Found", true}, - {"a 409 on the envelope write", func(ctx context.Context) error { + {"the host is unavailable", refusing(codes.Unavailable), "code = Unavailable", true}, + {"the host is out of time", refusing(codes.DeadlineExceeded), "code = DeadlineExceeded", true}, + {"the host is out of capacity", refusing(codes.ResourceExhausted), "code = ResourceExhausted", true}, + {"no answer: connection refused", deliverTo(t, refused), "code = Unavailable", true}, + {"the endpoint serves no composition interface", deliverTo(t, serveComposition(t, nil)), "code = Unimplemented", false}, + {"an internal failure", refusing(codes.Internal), "code = Internal", false}, + {"a manifest the host cannot read", refusing(codes.InvalidArgument), "code = InvalidArgument", false}, + {"a carrier the host denies", refusing(codes.PermissionDenied), "code = PermissionDenied", false}, + {"a stale generation", refusing(codes.FailedPrecondition), "code = FailedPrecondition", false}, + {"legacy HTTP: a 503 from the gateway's envelope read", readEnvelope(answering(t, http.StatusServiceUnavailable, "JWT validation not configured")), "HTTP 503", true}, + {"legacy HTTP: a 404 from the gateway before it exposes the route", readEnvelope(answering(t, http.StatusNotFound, "endpoint not exposed")), "404 Not Found", true}, + {"legacy HTTP: a 409 on the envelope write", func(ctx context.Context) error { _, err := envelopeWriter(answering(t, http.StatusConflict, "revision moved")).Ensure(ctx, func(uint64) ([]byte, error) { return []byte(`{}`), nil }) return err }, "HTTP 409", true}, - {"no answer: connection refused", readEnvelope(refusedURL), "connection refused", true}, - {"a 500 from the host's delivery endpoint", postManifest(t, answering(t, http.StatusInternalServerError, `{"error":"boom"}`)), "(500 Internal Server Error): boom", true}, - {"a 404 from the host's delivery endpoint: a wrong endpoint", postManifest(t, answering(t, http.StatusNotFound, `{"error":"no such inbox"}`)), "(404 Not Found): no such inbox", false}, - {"a 409 from the host's delivery endpoint: a different document", postManifest(t, answering(t, http.StatusConflict, `{"error":"different document"}`)), "(409 Conflict): different document", false}, - {"a 403 from the gateway", readEnvelope(answering(t, http.StatusForbidden, "not a platform administrator")), "HTTP 403", false}, - {"a gateway address that is not a URL", readEnvelope("http://[::1"), "missing ']'", false}, + {"legacy HTTP: no answer: connection refused", readEnvelope(refusedURL), "connection refused", true}, + {"legacy HTTP: a 500 from the host's delivery endpoint", postManifestHTTP(t, answering(t, http.StatusInternalServerError, `{"error":"boom"}`)), "(500 Internal Server Error): boom", true}, + {"legacy HTTP: a 404 from the host's delivery endpoint: a wrong endpoint", postManifestHTTP(t, answering(t, http.StatusNotFound, `{"error":"no such inbox"}`)), "(404 Not Found): no such inbox", false}, + {"legacy HTTP: a 409 from the host's delivery endpoint: a different document", postManifestHTTP(t, answering(t, http.StatusConflict, `{"error":"different document"}`)), "(409 Conflict): different document", false}, + {"legacy HTTP: a 403 from the gateway", readEnvelope(answering(t, http.StatusForbidden, "not a platform administrator")), "HTTP 403", false}, + {"legacy HTTP: a gateway address that is not a URL", readEnvelope("http://[::1"), "missing ']'", false}, {"a manifest the host has not applied for a reason it names", func(context.Context) error { return errors.New("module model is delivered and not served: the host rejected generation 1: binding withdrawn") }, "not served", false}, @@ -135,9 +219,16 @@ func TestANativeDeliveryRetriesExactlyTheRefusalsThatMeanNotServingYet(t *testin } } +// A host that is unavailable twice, then accepts, through the real client. +func TestANativeDeliveryRefusedAsUnavailableIsRetriedUntilAccepted(t *testing.T) { + host := &refusingComposition{refusals: []codes.Code{codes.Unavailable, codes.Unavailable}} + require.NoError(t, testDelivery(hostAlwaysReady, deliverTo(t, serveComposition(t, host))).run(context.Background())) + require.Equal(t, 3, host.calls) +} + // The shape of the platform-core run end to end through the real envelope // client: two 503s, then the answer that lets the delivery proceed. -func TestANativeDeliveryRefusedWith503IsRetriedUntilAccepted(t *testing.T) { +func TestALegacyHTTPDeliveryRefusedWith503IsRetriedUntilAccepted(t *testing.T) { var reads atomic.Int64 server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { if request.URL.Path == "/v1/auth/authenticate" { @@ -157,9 +248,9 @@ func TestANativeDeliveryRefusedWith503IsRetriedUntilAccepted(t *testing.T) { } // At the deadline the error carries why the host is still not serving and the -// last refusal it answered, status and body. +// last refusal it answered. func TestANativeDeliveryAtTheDeadlineNamesTheReasonAndTheLastRefusal(t *testing.T) { - refusing := readEnvelope(answering(t, http.StatusServiceUnavailable, "JWT validation not configured")) + refusing := deliverTo(t, serveComposition(t, &refusingComposition{refusals: []codes.Code{codes.Unavailable, codes.Unavailable, codes.Unavailable, codes.Unavailable}})) ready := true delivery := testDelivery(func(context.Context) error { if ready { @@ -172,14 +263,19 @@ func TestANativeDeliveryAtTheDeadlineNamesTheReasonAndTheLastRefusal(t *testing. err := delivery.run(context.Background()) 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") + require.ErrorContains(t, err, "refused with Unavailable") } // fakeLauncherFlow answers readiness per service from ready, and counts the // deliveries that reached the host's address — the first flow read of a // delivery attempt, after its preconditions. type fakeLauncherFlow struct { - ready func(service string) bool + ready func(service string) bool + runtime string + // address, when set, is every endpoint's address. + address string + // routing, when set, is the routing snapshot check's refusal. + routing error mu sync.Mutex attempts int @@ -190,16 +286,39 @@ func (f *fakeLauncherFlow) World() *orchestration.World { return nil } func (f *fakeLauncherFlow) ServiceFromUnique(string) (*resources.Service, error) { return nil, errors.New("no service in this test") } -func (f *fakeLauncherFlow) GetAddressForEndpoint(context.Context, string, string, string) (string, error) { +func (f *fakeLauncherFlow) CheckRoutingSnapshot(context.Context, *resources.Workspace) error { + return f.routing +} +func (f *fakeLauncherFlow) attempt() { f.mu.Lock() defer f.mu.Unlock() f.attempts++ f.attemptedBeforeHostReady = f.attemptedBeforeHostReady || !f.ready("saas/auth-gateway") || !f.ready("saas/accounts") +} +func (f *fakeLauncherFlow) GetAddressForEndpoint(context.Context, string, string, string) (string, error) { + f.attempt() + if f.address != "" { + return f.address, nil + } return "", errors.New("no address in this test") // refused at once: the delivery ends after this one attempt } +func (f *fakeLauncherFlow) GRPCEndpointTarget(context.Context, string, string, string) (orchestration.GRPCTarget, error) { + f.attempt() + return orchestration.GRPCTarget{}, errors.New("no address in this test") +} +func (f *fakeLauncherFlow) ServiceRuntimeContext(string) string { + if f.runtime != "" { + return f.runtime + } + return resources.RuntimeContextNative +} func (f *fakeLauncherFlow) EndpointCarriers(context.Context, string, *basev0.NetworkAccess) (map[string]string, error) { return nil, errors.New("no endpoints in this test") } +func (f *fakeLauncherFlow) ReadyEndpointCarriers(context.Context, string, *basev0.NetworkAccess, []string) (map[string]string, error) { + return nil, errors.New("no endpoints in this test") +} +func (f *fakeLauncherFlow) LocalRuntimeServices() []string { return []string{"model/api"} } func (f *fakeLauncherFlow) ServiceReadiness(_ context.Context, service string) *orchestration.ReadinessFailure { if f.ready(service) { return nil @@ -209,13 +328,15 @@ func (f *fakeLauncherFlow) ServiceReadiness(_ context.Context, service string) * // launcherWithFakeFlow composes module "model" for a host whose gateway is // saas/auth-gateway and whose delivery service is saas/accounts. +// The host, saas, is composed and declares no codefly.dev/composition: the +// legacy HTTP delivery is the one the run takes. func launcherWithFakeFlow(t *testing.T, ready func(string) bool) (*launcher, *fakeLauncherFlow, string) { - workspace, err := resources.LoadWorkspaceFromDir(context.Background(), writeNonGoWorkloadDir(t, "model")) + workspace, err := resources.LoadWorkspaceFromDir(context.Background(), writeLegacyHostedDir(t, "model")) require.NoError(t, err) unique := "model/api" flow := &fakeLauncherFlow{ready: ready} return &launcher{ - workspace: workspace, flow: flow, hostModule: "saas", hostService: "accounts", + workspace: workspace, flow: flow, hostModule: "saas", hostService: "accounts", hostEndpoint: "rest", env: &environments.Environment{Host: &environments.EnvironmentHost{Gateway: "saas/auth-gateway", Delivery: "saas/accounts/rest"}}, identities: map[string]gitops.NativeIdentity{unique: {Module: "model", Service: "api"}}, running: map[string]bool{}, @@ -224,6 +345,45 @@ func launcherWithFakeFlow(t *testing.T, ready func(string) bool) (*launcher, *fa }, flow, unique } +// writeLegacyHostedDir is writeNonGoWorkloadDir with the saas host composed: +// its accounts service's rest endpoint implements no Codefly interface. +func writeLegacyHostedDir(t *testing.T, name string) string { + t.Helper() + root := writeNonGoWorkloadDir(t, name) + config := filepath.Join(root, resources.WorkspaceConfigurationName) + data, err := os.ReadFile(config) + require.NoError(t, err) + composed := strings.Replace(string(data), " - name: "+name+"\n", " - name: "+name+"\n - name: saas\n", 1) + require.NotEqual(t, string(data), composed) + require.NoError(t, os.WriteFile(config, []byte(composed), 0o644)) + for relative, content := range map[string]string{ + filepath.Join("modules", "saas", resources.ModuleConfigurationName): "kind: module\nname: saas\nservices:\n - name: accounts\n", + filepath.Join("modules", "saas", "services", "accounts", resources.ServiceConfigurationName): "kind: service\nname: accounts\nversion: 0.0.0\nagent:\n kind: runtime::service\n name: go\n version: 0.0.1\n publisher: codefly.dev\nendpoints:\n - name: rest\n api: rest\n visibility: internal\n", + } { + full := filepath.Join(root, relative) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(content), 0o644)) + } + return root +} + +// The transport is the host's declaration, never a fallback: a host whose +// delivery endpoint declares no codefly.dev/composition takes the legacy HTTP +// delivery, its envelope written through the gateway; a host whose module does +// not load is a refusal naming why, with nothing delivered. +func TestTheDeliveryTransportIsTheHostsDeclaration(t *testing.T) { + cluster, flow, _ := launcherWithFakeFlow(t, func(string) bool { return true }) + _, _, err := cluster.newDeliverer(context.Background()) + require.ErrorContains(t, err, "the host's delivery endpoint is unresolved") + require.Equal(t, 1, flow.attempts, "the legacy HTTP route was resolved, not the composition interface") + + module := filepath.Join(cluster.workspace.Dir(), "modules", "saas", resources.ModuleConfigurationName) + require.NoError(t, os.WriteFile(module, []byte("kind: module\nname: saas\nservices: {not: a list}\n"), 0o644)) + _, _, err = cluster.newDeliverer(context.Background()) + require.ErrorContains(t, err, "cannot tell whether host saas/accounts implements codefly.dev/composition") + require.Equal(t, 1, flow.attempts, "an unreadable host is resolved over no transport") +} + // A module Running before the gateway is delivered once the gateway is ready, // once, and not before. func TestAModuleRunningBeforeTheGatewayIsDeliveredOnceTheGatewayIsReady(t *testing.T) { @@ -303,3 +463,34 @@ func TestARestartedWorkloadSupersedesItsWaitingDelivery(t *testing.T) { delivery.stop = stop require.ErrorIs(t, delivery.run(context.Background()), errRunStopped) } + +// These helpers drive a completed attempt through runReadyDelivery without +// starting an agent. Production completion is wired at Runner.Start. +func (cluster *launcher) registerRunningHook(ctx context.Context, register func(func(string, int))) { + register(func(service string, _ int) { + if deliver := cluster.runningDelivery(service); deliver != nil { + go deliver(ctx) + } + }) +} + +func (cluster *launcher) onRunning(ctx context.Context, unique string) { + if deliver := cluster.runningDelivery(unique); deliver != nil { + deliver(ctx) + } +} + +// Capture readiness synchronously with the lifecycle notification, before the +// delivery goroutine. A restart can then invalidate it without an old queued +// callback marking the replacement process ready. +func (cluster *launcher) runningDelivery(unique string) func(context.Context) { + identity, ok := cluster.identity(unique) + if !ok || !cluster.delivers(identity) { + return nil + } + cluster.mu.Lock() + cluster.running[unique] = true + waiter := cluster.waitFor(unique) + cluster.mu.Unlock() + return func(ctx context.Context) { cluster.runReadyDelivery(ctx, unique, identity, waiter, false) } +} diff --git a/cmd/run/native_prerequisites.go b/cmd/run/native_prerequisites.go new file mode 100644 index 000000000..1116feeb4 --- /dev/null +++ b/cmd/run/native_prerequisites.go @@ -0,0 +1,66 @@ +package run + +import ( + "context" + "fmt" + "strings" + + "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" +) + +// NativeAuthorityPrerequisites orders native identity workloads after the host +// that admits their signed manifests. It grants no endpoint or configuration +// access; Core's runtime graph owns scheduling and cycle validation. +func NativeAuthorityPrerequisites(env *environments.Environment, runtimeContext string) orchestration.FlowOption { + var modules []string + if env == nil || env.Host == nil || runtimeContext == resources.RuntimeContextContainer { + return orchestration.WithRuntimePrerequisites(nil, nil) + } + for _, ref := range []string{env.Host.Delivery, env.Host.Gateway} { + if module, _, ok := strings.Cut(ref, "/"); ok { + modules = append(modules, module) + } + } + return orchestration.WithRuntimePrerequisites(modules, func(ctx context.Context, workspace *resources.Workspace) (map[string][]string, error) { + if err := env.Host.Validate(); err != nil { + return nil, err + } + hosts, err := nativeHostServices(ctx, workspace, env) + if err != nil { + return nil, err + } + identities, err := gitops.ResolveNativeIdentities(ctx, workspace, env) + if err != nil { + return nil, err + } + hostModule := strings.Split(env.Host.Delivery, "/")[0] + prerequisites := map[string][]string{} + for unique, identity := range identities { + if identity.Module != hostModule { + prerequisites[unique] = hosts + } + } + return prerequisites, nil + }) +} + +// The delivery endpoint selects both transport and readiness. Composition +// delivery calls the host directly; only legacy envelope delivery uses a gateway. +func nativeHostServices(ctx context.Context, workspace *resources.Workspace, env *environments.Environment) ([]string, error) { + parts := strings.Split(env.Host.Delivery, "/") + if len(parts) != 3 { + return nil, fmt.Errorf("host.delivery %q is not //", env.Host.Delivery) + } + hosts := []string{parts[0] + "/" + parts[1]} + implements, err := gitops.HostImplements(ctx, workspace, parts[0], parts[1], parts[2], gitops.CompositionInterface) + if err != nil { + return nil, err + } + if !implements && env.Host.Gateway != "" { + hosts = append(hosts, env.Host.Gateway) + } + return hosts, nil +} diff --git a/cmd/run/paired_delivery_test.go b/cmd/run/paired_delivery_test.go index bfbe449ab..deed5bcce 100644 --- a/cmd/run/paired_delivery_test.go +++ b/cmd/run/paired_delivery_test.go @@ -13,12 +13,14 @@ import ( 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/orchestration" "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" + "google.golang.org/grpc/connectivity" ) type pairedLauncherFlow struct { @@ -26,8 +28,10 @@ type pairedLauncherFlow struct { address string } -func (f *pairedLauncherFlow) GetAddressForEndpoint(context.Context, string, string, string) (string, error) { - return f.address, nil +func (f *pairedLauncherFlow) GRPCEndpointTarget(context.Context, string, string, string) (orchestration.GRPCTarget, error) { + f.mu.Lock() + defer f.mu.Unlock() + return orchestration.GRPCTarget{Address: f.address}, nil } // The launcher witness checks actual factory wiring and filesystem readers. @@ -68,17 +72,23 @@ func TestLauncherReservesGenerationFromChangedSolutionRoles(t *testing.T) { 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()}} + flow := &pairedLauncherFlow{address: listener.Addr().String()} + cluster := &launcher{workspace: workspace, env: env, signer: signer, vouch: vouch, stateDir: t.TempDir(), hostModule: "model", hostService: "model", hostEndpoint: "rest", flow: flow, stop: make(chan struct{})} cluster.declare(map[string]gitops.NativeIdentity{"model/model": {Module: "model", Service: "model"}}) contractPath := filepath.Join(workspace.Dir(), "modules", "model", modulecontract.FileName) + var conns []*grpc.ClientConn 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. + // The production retry path constructs a new deliverer each attempt, + // over the run's one connection to the host. d, gateway, err := cluster.newDeliverer(t.Context()) require.NoError(t, err) - require.Empty(t, gateway) + require.Empty(t, gateway, "a composition host takes no envelope") require.NotNil(t, d.Prepare) + if conns = append(conns, cluster.host.conn); len(conns) > 1 { + require.Same(t, conns[0], cluster.host.conn, "every attempt reuses the run's connection to the host") + } 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"}}} @@ -86,6 +96,27 @@ func TestLauncherReservesGenerationFromChangedSolutionRoles(t *testing.T) { require.NoError(t, err) } peer.mu.Lock() - defer peer.mu.Unlock() require.Equal(t, []uint64{1, 1, 2, 2}, peer.generations) + peer.mu.Unlock() + + // A host answering at another address is dialled again, and the + // connection it replaces is closed; the run's teardown closes the last. + moved, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + movedServer := grpc.NewServer() + compositionv1.RegisterCompositionServiceServer(movedServer, peer) + go func() { _ = movedServer.Serve(moved) }() + t.Cleanup(movedServer.Stop) + flow.mu.Lock() + flow.address = moved.Addr().String() + flow.mu.Unlock() + _, _, err = cluster.newDeliverer(t.Context()) + require.NoError(t, err) + require.NotSame(t, conns[0], cluster.host.conn) + require.Equal(t, connectivity.Shutdown, conns[0].GetState()) + last := cluster.host.conn + cluster.close() + require.Equal(t, connectivity.Shutdown, last.GetState()) + _, _, err = cluster.newDeliverer(t.Context()) + require.ErrorIs(t, err, errRunStopped, "a stopped run dials nothing") } diff --git a/cmd/run/service.go b/cmd/run/service.go index 6e9631022..394d74e82 100644 --- a/cmd/run/service.go +++ b/cmd/run/service.go @@ -902,6 +902,7 @@ func newRunFlow(ctx context.Context, workspace *resources.Workspace, module *res flow, err := orchestration.NewFlow(ctx, workspace, module, service, env, orchestration.RunMode, orchestration.WithRunModuleClosure(runModuleSeeds(module, runCoRoots)...), + NativeAuthorityPrerequisites(env, runtimeContext), orchestration.WithCompositionCheck(gitops.PermissionPreflight(false))) if err != nil { return nil, w.Wrap(err) diff --git a/cmd/run/withdraw_http_test.go b/cmd/run/withdraw_http_test.go new file mode 100644 index 000000000..91de26bf6 --- /dev/null +++ b/cmd/run/withdraw_http_test.go @@ -0,0 +1,53 @@ +package run + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/cli/pkg/gitops" + "github.com/codefly-dev/cli/pkg/run/identity" + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" +) + +// Over the legacy HTTP delivery, a withdrawal the saas host refuses because it +// already holds one for the binding (a module re-added, refused, then dropped +// again) completes, rather than staying pending on every refresh. +func TestAWithdrawalTheHTTPHostAlreadyHoldsCompletes(t *testing.T) { + cluster, flow, _ := launcherWithFakeFlow(t, func(string) bool { return true }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "module manifest generation conflicts with this binding's history: " + + solutionhost.ErrWithdrawn.Error() + `: binding "acme.local.model" was withdrawn at generation 2 and the candidate is generation 4`}) + })) + t.Cleanup(server.Close) + flow.address = server.URL + cluster.env = &environments.Environment{Name: "local", Host: &environments.EnvironmentHost{Gateway: "saas/auth-gateway", Delivery: "saas/accounts/rest", TrustDomain: "cluster.local", Audience: "accounts"}} + cluster.stateDir = t.TempDir() + _, key, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + cluster.signer, err = solutionhost.NewInMemorySigner("test", key) + require.NoError(t, err) + cluster.vouch, err = identity.Start("cluster.local", "accounts", t.TempDir()) + require.NoError(t, err) + cluster.declare(map[string]gitops.NativeIdentity{"model/api": {Module: "model", Service: "api"}}) + cluster.pendingWithdrawals = map[string]bool{} + cluster.delivered["model"] = &solutionhost.ModuleManifest{ + Schema: solutionhost.SchemaModuleManifestV1, Binding: "acme.local.model", Alias: "model", Kind: solutionhost.KindModule, Generation: 3, + Host: solutionhost.HostTarget{Coordinate: "example/local/dev", Domain: "acme"}, + Release: solutionhost.Release{Publisher: "acme", Name: "model", Version: "0.1.0"}, + Workloads: []solutionhost.Workload{{Service: "api", Container: "api"}}, + Authority: &solutionhost.Authority{Principal: "model"}, + } + done, err := cluster.withdrawModule(context.Background(), "model") + require.NoError(t, err) + require.True(t, done) + require.Empty(t, cluster.pendingWithdrawals, "nothing stays pending") + require.True(t, cluster.delivered["model"].Removed) +} diff --git a/cmd/test/service.go b/cmd/test/service.go index 70aaa14f0..43d1cb18d 100644 --- a/cmd/test/service.go +++ b/cmd/test/service.go @@ -267,7 +267,7 @@ func initRunService(ctx context.Context, workspace *resources.Workspace, module return nil, w.Wrap(err) } - flow, err := orchestration.NewFlow(ctx, workspace, module, service, env, orchestration.TestMode) + flow, err := orchestration.NewFlow(ctx, workspace, module, service, env, orchestration.TestMode, run.NativeAuthorityPrerequisites(env, runtimeContext)) if err != nil { return nil, w.Wrap(err) } @@ -309,6 +309,9 @@ func initRunService(ctx context.Context, workspace *resources.Workspace, module if err != nil { return flow, w.Wrap(err) } + if attachErr := run.AttachNativeLauncher(ctx, flow, workspace, env, runtimeContext, selectedFixture); attachErr != nil { + return flow, w.Wrap(attachErr) + } err = flow.Load(ctx) if err != nil { return flow, w.Wrap(err) diff --git a/cmd/test/source.go b/cmd/test/source.go index ffa5ae791..7fa4d5f2a 100644 --- a/cmd/test/source.go +++ b/cmd/test/source.go @@ -9,6 +9,7 @@ import ( "github.com/blang/semver" "github.com/codefly-dev/cli/cmd/common" + "github.com/codefly-dev/cli/cmd/run" "github.com/codefly-dev/cli/pkg/cli" "github.com/codefly-dev/cli/pkg/orchestration" "github.com/codefly-dev/cli/pkg/sourceworkspace" @@ -186,7 +187,7 @@ func initSourceTest(ctx context.Context, prepared *sourceworkspace.Prepared, req if err != nil { return nil, err } - flow, err := orchestration.NewFlow(ctx, prepared.Workspace, prepared.Module, prepared.Service, env, orchestration.TestMode) + flow, err := orchestration.NewFlow(ctx, prepared.Workspace, prepared.Module, prepared.Service, env, orchestration.TestMode, run.NativeAuthorityPrerequisites(env, sourceRuntimeContext)) if err != nil { return nil, err } @@ -196,6 +197,9 @@ func initSourceTest(ctx context.Context, prepared *sourceworkspace.Prepared, req if err := flow.InitManagers(ctx); err != nil { return flow, err } + if err := run.AttachNativeLauncher(ctx, flow, prepared.Workspace, env, sourceRuntimeContext, ""); err != nil { + return flow, err + } if err := flow.Load(ctx); err != nil { return flow, err } diff --git a/docs/commands.md b/docs/commands.md index 40e6e4340..e6188cae4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -64,11 +64,6 @@ 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. @@ -201,14 +196,16 @@ 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. +before native delivery, only when what the build reads changed: its npm +project's files, tracked or new (never an untracked `.env`), the local +packages it depends on, and its lockfile. It builds a staged copy of exactly +those files with the remote's own `npm ci` (when the lockfile changed) and +`npm run build`, natively, or in the pinned Node image when the service owning +the page runs in a container, and before the run-wide delivery lock. 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...]` diff --git a/docs/development.md b/docs/development.md index e9735062b..f18310d75 100644 --- a/docs/development.md +++ b/docs/development.md @@ -385,9 +385,10 @@ 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 +for the run's connection to the host. Native withdrawal completion uses the `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 +private HTTP route or mint an observer credential for an interface host. A +host without the interface retains the legacy HTTP observer until that path +is deleted. 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 1aa20eba9..16aece801 100644 --- a/docs/module-manifests.md +++ b/docs/module-manifests.md @@ -102,23 +102,37 @@ 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. 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 +project nothing. Before native delivery, Codefly builds every declared page; +no manual bundle build is required. The build is keyed by exactly what it +reads, and reads a staged copy of exactly that: the files of its npm project +(`services/fe-remote`, or the module root when only that holds a +`package.json`, then without the services' code and every Go source) that its +repository tracks or does not ignore — but never an untracked dotenv file +(`.env`, `.env.*`), which the deployed build, reading the committed tree, +never sees — the same of every local package it depends on (a `file:` or +`link:` dependency naming a directory, transitively), every file such a +dependency names (a vendored `.tgz`, at the path npm resolves, so a cold npm +cache installs it from the copy; replacing it installs and builds again), and +the top-level regular files of each +directory above the project (a tsconfig base, an `.npmrc`). Unchanged sources +whose bundle is still retained build nothing, so a Go edit that restarts a +service never rebuilds the page, while a new file in the project or an edit +to a sibling package does; the record and the staged copy are kept under +`.codefly/run//frontend/`. A module's staged copy, with its +`node_modules`, is removed when the module leaves the composition and every +copy when the run stops; the records stay, so a bundle still retained is +reused. Past 2 GiB of staged copies in an environment, the least recently +built copies of other modules are removed after a build. The build runs before the run-wide +delivery lock is taken, so it delays no other module's delivery. It runs the +frontend's own npm build natively in the staged copy (`npm ci` first when the +copy's dependencies were not installed from this lockfile, then `npm run +build`), with no variable of the shell but what finds npm and its registry, so +the component's own `node_modules` and files are never touched; a leftover +`dist` is never a serving claim. When the service that owns the page runs in a +container, the same copy builds in the pinned builder image as deployment +does. A symlink or special file among the sources is refused by name, since +the deployed build refuses it too. The bundle is archived in Core's canonical +form and retained in `.codefly/selection-artifacts` under its digest, and 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. @@ -1101,11 +1115,23 @@ run, the CLI is: once the host is ready to take it (below), the run builds its module's manifest (the same `moduleInstanceOf` and `ModuleManifestOf` a cell's render uses), listing **every** identity workload of the module that runs now, with the addresses this run allocated - for its routed endpoints, signs it with the developer key and POSTs it to - `/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. For a composition-interface host, that includes + for its routed endpoints, signs it with the developer key and carries it, + with a native credential for account `delivery` in the module's namespace, + to `ApplyManifest` of the `codefly.dev/composition` interface the + `host.delivery` endpoint declares in its module's interface endpoints. A + host whose delivery endpoint declares no such interface — the saas host on + main — receives it through the legacy HTTP route instead, POSTed to + `/platform/_delivery/manifest`, with the envelope written through its + gateway (below); the run logs that reason on every delivery that takes it. + A host whose module does not load receives nothing, with the reason; the + run never guesses a transport. The legacy route is deleted once no + composition delivers to a host without the interface. The run opens one + connection to a composition endpoint — at the address its recorded instance answers, + with TLS when the endpoint declares itself `secured`, exactly as readiness + dials it — reuses it for every delivery, retry and withdrawal, dials again + only when the host answers at another address, and closes it when the run + stops. When the set of running identity workloads changes, the manifest is + delivered again; its generation is settled from its content, which 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. @@ -1116,17 +1142,24 @@ run, the CLI is: 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 + Core-only identity and read no policy source, so a withdrawal replays its + exact generation. The record never refuses a delivery: a module re-added after + its withdrawal is the next generation, and whether the withdrawal is terminal + is the host's to say. Core's admission keeps it terminal for as long as the + host holds it; the local authority holds applied deliveries in memory only, so + its refusal says to stop the run and start it again, after which the host + accepts the module at that generation. A withdrawal the host refuses because + it already holds one for the binding is complete, over the composition + interface (a `FailedPrecondition`) and over the legacy HTTP route (the saas + host's 409) alike. Neither carries a typed signal for it yet, so the refusal + is recognized by core's `ErrWithdrawn` wording, a stopgap until + codefly-dev/authority#2. 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** — over the legacy HTTP delivery, before delivering, it + makes sure the host holds the composition's envelope: `{"revision": N, "principals": {"": {"grant": }}}`, each grant read from the `principals` of `configurations//module-envelope.json`, resolved through the @@ -1140,47 +1173,56 @@ run, the CLI is: the fixture's `super_admin`, at the next revision and only when it changed. A composition stating no grant writes none. -**A delivery waits for the host, within a deadline.** A workload reports -Running on its own schedule: a module the gateway does not depend on can be -Running before `host.gateway` has started, and a gateway that listens can -still answer 503 until it has configured itself. So a workload's delivery -starts only once the gateway and the `host.delivery` service are ready by the -readiness the run gates on (`Flow.ServiceReadiness`: Start completed, consumed -endpoints answering), and a refusal that only says the host is not serving -yet is asked again with a backoff from 0.5 s doubling to 10 s, for at most -**10 minutes** from Running: a 5xx; a 404 from the gateway (sign-in or -envelope) before it exposes the route; a 409 on the envelope (a lost revision -race); no answer at all (refused, reset, timed out). A refusal that says no -fails at once with its reason: any other 4xx, including a 404 from the host's -own delivery endpoint (a wrong `host.delivery`), a policy refusal, an address -that is not a URL, a manifest the host received and withheld with a reason. The first -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 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. +**Authority admission precedes local Start.** The native launcher adds runtime +prerequisites through Core's dependency graph, retaining the delivery host when +module closure pruning would otherwise remove it. This ordering grants no +endpoint access and changes no build dependency. A composition-interface host +requires its delivery service; legacy delivery also requires its gateway. + +Each attempt registers cleanup before stopping the prior process or preparing +its request. Immediately before an identity workload's Start RPC, the launcher +applies declarations +for the selected local runtime services and their binding targets. Remote +services, excluded origins, dormant pinned services and no-start test modes do +not acquire local workload membership. A binding targeting a module without a +selected declaration is refused by name before any declaration is sent. The +host's own module is not delivered through this path, so a binding targeting it +is also refused; host process readiness alone does not prove audience registration. Run, test service and test source use the +same launcher and completion boundary; a test that starts no services does not +load a native launcher. + +Declared membership is distinct from readiness. The pre-start manifest lists +selected identity workloads but advertises only endpoints of successful starts. +Ready services are filtered before address resolution. Cold authority admission +can defer a frontend bundle; when this launcher already published a page, it +supplies that payload during unrelated partial startup rather than removing it. +Normal frontend publication requires the complete built payload. + +Each attempt waits for host readiness and Applied, within **10 minutes**. Over +the composition interface, `Unavailable` (no answer at all among them), +`DeadlineExceeded` and `ResourceExhausted` use the existing bounded backoff; +every other code — `Unimplemented` (an endpoint that serves no composition +interface), `Internal`, `InvalidArgument`, `PermissionDenied`, +`FailedPrecondition` (a stale or rewritten generation, a held withdrawal) and +any code the run does not classify — fails at once with its reason. Over the +legacy HTTP delivery, a 5xx, a transport outage, a gateway-route 404 or an +envelope revision conflict uses the backoff; policy refusals and wrong delivery +endpoints fail at once. The deadline also bounds network calls and waiting for the delivery lock. +Stopping a run or superseding an attempt cancels its active delivery. A lost +response does not establish whether a host applied the manifest. + +A request-preparation error, Start failure, missing response or cancellation stops refreshing that +attempt's projected credential and leaves its selected declaration unready. +It does **not** send a terminal withdrawal: that would prohibit a normal retry +with the same binding. Successful retry reprojects through the existing native +identity owner. Init receives the resolved credential path without issuing a credential; attempt +setup owns issuance and refresh membership. Attempt identity fences completion so an old failure cannot +forget the new projection and an old success cannot advertise stale endpoints. +Full flow teardown stops refresh even after partial manager initialization. +This is readiness cleanup and bounded credential expiry, not admission rollback +or immediate revocation. A copied native credential retains its existing lifetime +(up to 600 seconds after issuance); already-issued Work Contexts have their own +expiry and revocation rules. Process teardown remains required for partial starts. The host is pointed at the stand-in by `CODEFLY_NATIVE_IDENTITY_ANCHOR` and `SOLUTION_HOST_LOCAL_TRUST_ANCHOR`, both under @@ -1188,19 +1230,26 @@ The host is pointed at the stand-in by `CODEFLY_NATIVE_IDENTITY_ANCHOR` and delivery host turns its surface on from the `federation` keys `SOLUTION_HOST_COORDINATE`, `SOLUTION_HOST_OWNERSHIP_DOMAINS` and `SOLUTION_HOST_TRUST_POLICY`, which a native run derives from the host block. +The delivery service needs no authored `federation` file when these are its only +values. Authored registration secrets remain supported and pass the usual secret +and endpoint checks; ambiguous groups still fail. The generated fields reach only +the declaring delivery host. Other services, container runs and deployments retain +their authored requirements. `doctor workspace` reports this native-only supply as +a warning because it does not select the runtime. **Growing and shrinking the run.** Every `codefly run` admits the components a reloaded composition adds when it receives `SIGUSR1`: their identities are planned by the rule the run started with (a known component may neither change -nor disappear), and they are delivered through the same Running hook. A root +nor disappear), and they use the same pre-start admission and completion path. A root the reloaded composition no longer names is stopped, and each stopped identity -workload is taken out of its module's manifest: a module that still runs an -identity workload is delivered again listing what remains, and one that runs -none is **withdrawn** — its manifest at the next generation with -`removed: true`, terminal for the binding — and the run waits, up to a minute, -until the host's read-only applied record -(`GET /platform/_delivery/applied/`, read as the endpoint-publisher) -holds that exact withdrawal. A withdrawal not yet applied stays pending and is +workload is taken out of its module's manifest: a module that still has a +selected identity workload is delivered again listing what remains, and one +with none is **withdrawn** — its manifest at the next generation with +`removed: true`, which the host keeps terminal for the binding while it holds +it — and the run waits, up to a minute, until the applied record +`ApplyManifest` acknowledged (over the legacy HTTP delivery, the host's +read-only `GET /platform/_delivery/applied/`, read as the +endpoint-publisher) holds that exact withdrawal. A withdrawal not yet applied stays pending and is redelivered unchanged (the host replays it) on the next signal. ## Dev deployments @@ -1220,8 +1269,9 @@ in-process executor. No cell has received a delivered module envelope yet; the envelope route is the host's parallel change, verified against core's verifier only. A native run -still writes the envelope as the fixture's administrator; converging it onto -the delivered path is named and not built. +delivering through the composition interface writes no envelope, the manifest +being the ceiling; over the legacy HTTP delivery it still writes the envelope +as the fixture's administrator. 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 diff --git a/docs/orchestration.md b/docs/orchestration.md index 87f39ee03..5702d08b9 100644 --- a/docs/orchestration.md +++ b/docs/orchestration.md @@ -399,17 +399,15 @@ 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. +module-closure run includes producers of groups its services declare, using +Core's configuration-reference closure option with the same invocation snapshot +used for graph edges and validation. Thus a hop's generated routes bring their +producers into the run. Root-only groups do not grow the module closure; a +reference to a producer outside that run is dropped at WARN with its consumer, +value and producer named. Endpoint existence and visibility still apply. A +producer the workspace lacks is refused, and an explicit run exclusion +(`--exclude-dependency` or a run profile) coupled to a declared reference is +refused rather than silently dropping that value. ##### What this refuses that used to be tolerated @@ -867,14 +865,6 @@ At each transition, the policy checks the dependency graph: if service B depends 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 The agent's test dependency mode selects what starts before the target's test: @@ -1199,3 +1189,44 @@ advertisement never grants a skip. A supported operation that returns Unimplemen remains a contract failure. CI records unsupported operations as skipped with `agent_no_lint_capability`, `agent_no_compile_capability`, or `agent_no_test_capability`, rather than reporting a successful check. +## Published hop routes + +A hop declaring the `hop-routes` workspace group receives a generated route +snapshot for the principals named by `hop/MODULES`. Do not author that group or +an RPC-to-address table. Codefly reads the selected modules' published API catalogs, +checks their package identity and contract digests, and derives exact endpoint +references. Run and render feed those references through the same Core loader, +dependency graph, visibility checks and address resolver. Only the declaring +consumer receives the generated group; it is not a composition-root group. + +This requires published catalogs from `codefly generate contracts`. A source +file that has not been exported does not create a route. Unknown principals, +stale contract artifacts, conflicting destinations, and unreachable exact +endpoints refuse before a hop starts. The hop keeps authority enforcement: +routes identify a listener and policy method, never grants or readiness. + +Each listener's methods are the method policy table delivered to the +authority for that listener, read by the same reader from the digest-checked +contract bytes: a protobuf listener's served procedures, an OpenAPI listener's +operations, ` ` (a Swagger 2.0 `basePath` prefixes both +sides alike). The catalog's own route list never names a route, so a catalog +that omits a stricter route its document declares cannot make the hop check that +call against a looser template. Which procedures a protobuf listener serves is +the catalog's record, for the routes and the delivered table alike. Inventory +comes from `codefly generate contracts`. This path does not +prove that unexported source is current or that a running listener registers +every published method; generation checks and live module qualification remain +separate requirements. + +The snapshot lasts for one run. Ordinary source reload keeps accepted endpoint +addresses. Changing the published route inventory or a used address requires +stopping and restarting the run. `SIGUSR1`, withdrawal, extension, loader +reload, source-watch admission, startup admission and readiness delivery reject +a changed snapshot before stopping services, adopting declarations or +delivering a policy table read since; endpoint publication cannot +replace an address already handed to a snapshot. An address first learned from +an agent's Init response is rejected before publication; this does not undo that +agent's process effects. This is an explicit restart +boundary, not live route reconfiguration. Existing hop versions that do not +declare `hop-routes` keep their existing configuration mode; a new hop requires +both the CLI projection and the generated group. diff --git a/go.mod b/go.mod index 6bd56e92f..77892c892 100644 --- a/go.mod +++ b/go.mod @@ -11,10 +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/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/authority/libraries/authority-client/go v0.0.0-20261025214748-c9cef7470d20 + github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025214748-c9cef7470d20 + github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025214748-c9cef7470d20 + github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0 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 diff --git a/go.sum b/go.sum index f3ea48bc0..56e7b3aab 100644 --- a/go.sum +++ b/go.sum @@ -156,12 +156,26 @@ github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGn 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/authority-client/go v0.0.0-20261025205543-cac1f6b3dcb6 h1:xme0meaIpxN5g3kQX/s4x8WxRdzwdN0r6xIJwMIY0RM= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025205543-cac1f6b3dcb6/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025214748-c9cef7470d20 h1:qZ5BZUPRlBosM5eeJnGScon9ibxJOvjb22UxTcESSdU= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025214748-c9cef7470d20/go.mod h1:Oher/sd93bXgASlhZgR1BAdrbxc7QLuGPCkDXllHvWU= 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/delivery/go v0.0.0-20261025205543-cac1f6b3dcb6 h1:CPC9lwl5KgLccAs+42rghjAr/NOotVHRXi4Q3L+AkQI= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025205543-cac1f6b3dcb6/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025214748-c9cef7470d20 h1:mJr9YC9YEkXkqGVwE0ml+RhrmGJqn+aNgKEviDb/sB4= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261025214748-c9cef7470d20/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/authority/libraries/hop/go v0.0.0-20261025205543-cac1f6b3dcb6 h1:zUGZPAZZCEuhTrGgjJohq2CGCeM7t+OB8rufnPmbklA= +github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025205543-cac1f6b3dcb6/go.mod h1:021MSgdhW9pSlRLcHEryPDXfI/rGY49rJAhe4RbU7BQ= +github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025214748-c9cef7470d20 h1:zokkjvY+C+FhXq9Tuo93wJBVwLxcX4RfHy8U8KeAAW8= +github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025214748-c9cef7470d20/go.mod h1:021MSgdhW9pSlRLcHEryPDXfI/rGY49rJAhe4RbU7BQ= 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/core v0.19.7-0.20261025185759-e501458995d0 h1:YEFvkyokBsUWbHoaoKOi6iJtMrqNNR7HsWdUQGN50TA= +github.com/codefly-dev/core v0.19.7-0.20261025185759-e501458995d0/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= @@ -555,9 +569,9 @@ github.com/sigstore/rekor-tiles/v2 v2.3.0/go.mod h1:DEFiKSyQ4nF75QRVNdOPaIH3cmvM 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.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 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= @@ -565,21 +579,21 @@ 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= diff --git a/pkg/environments/module_authority.go b/pkg/environments/module_authority.go index 90a18e9c0..34e4e64b8 100644 --- a/pkg/environments/module_authority.go +++ b/pkg/environments/module_authority.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/codefly-dev/cli/pkg/runnables" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/resources" ) @@ -121,3 +122,29 @@ func ValidateHostComposition(ctx context.Context, workspace *resources.Workspace } return nil } + +// NativeHostFederation derives only the delivery host's public federation keys. +// Registration secrets remain authored configuration. A container or cell render +// does not use this native projection. +func NativeHostFederation(host *EnvironmentHost, service *resources.Service) (*basev0.Configuration, error) { + if host == nil || service == nil || !slices.Contains(service.WorkspaceConfigurationDependencies, FederationConfiguration) { + return nil, nil + } + if !deliveryPattern.MatchString(host.Delivery) { + return nil, fmt.Errorf("host.delivery %q is not //", host.Delivery) + } + module, name, _ := host.DeliveryEndpoint() + if resources.WithUnique(service).Unique() != resources.ServiceUnique(module, name) { + return nil, nil + } + if host.Coordinate == "" || host.Domain == "" { + return nil, fmt.Errorf("the delivery host requires host.coordinate and host.domain") + } + return &basev0.Configuration{Origin: resources.ConfigurationWorkspace, Infos: []*basev0.ConfigurationInformation{{ + Name: FederationConfiguration, ConfigurationValues: []*basev0.ConfigurationValue{ + {Key: FederationHostCoordinateKey, Value: host.Coordinate}, + {Key: FederationHostOwnershipDomainsKey, Value: host.Domain}, + {Key: FederationHostTrustPolicyKey, Value: FederationHostTrustPolicyKeys}, + }, + }}}, nil +} diff --git a/pkg/gitops/cell.go b/pkg/gitops/cell.go index b0884ba3e..a202e820b 100644 --- a/pkg/gitops/cell.go +++ b/pkg/gitops/cell.go @@ -17,7 +17,6 @@ import ( "github.com/codefly-dev/cli/pkg/orchestration" "github.com/codefly-dev/cli/pkg/run/fixture" "github.com/codefly-dev/cli/pkg/solutionrun" - "github.com/codefly-dev/core/configurations" "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/shared" "gopkg.in/yaml.v3" @@ -666,7 +665,7 @@ type configuredReference struct{ source, reference string } // reading one dials that endpoint, so it is a consumer of it exactly as a // service dependency makes it one. func configuredEndpointReferences(ctx context.Context, workspace *resources.Workspace, env *environments.Environment) (map[string][]configuredReference, error) { - reader, err := configurations.NewConfigurationLocalReader(ctx, workspace) + reader, err := orchestration.NewWorkspaceConfigurationReader(ctx, workspace) if err != nil { return nil, err } diff --git a/pkg/gitops/composition_delivery.go b/pkg/gitops/composition_delivery.go index 3616d2590..1d632035b 100644 --- a/pkg/gitops/composition_delivery.go +++ b/pkg/gitops/composition_delivery.go @@ -3,7 +3,6 @@ package gitops import ( "context" "fmt" - "net/http" "sync" "github.com/codefly-dev/core/solutionhost" @@ -54,20 +53,6 @@ func NewCompositionDelivery(conn grpc.ClientConnInterface, signer solutionhost.S 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 { @@ -82,8 +67,7 @@ func (c *CompositionDelivery) post(ctx context.Context, module string, signed *s } 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)} + return Receipt{}, refusal(module, manifest, 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) @@ -97,7 +81,7 @@ func (c *CompositionDelivery) post(ctx context.Context, module string, signed *s 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 + return Receipt{Disposition: "accepted", Generation: out.GetGeneration()}, nil } // Prepare captures one declaration. Deterministic bytes both identify its @@ -187,21 +171,19 @@ func (c *CompositionDelivery) Applied(_ context.Context, binding string) (*Appli 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 +// refusal is the host's answer to ApplyManifest as the run reports it: its +// code kept for the retry classification (HostStatusError.Transient), and its +// reason. A host that still holds the binding's withdrawal keeps it terminal +// (core's admission, solutionhost.ErrWithdrawn): the refusal then says what +// the person can do, since the binding is derived from the workspace, the +// environment and the module and is not theirs to choose. +// The withdrawal is recognized by a stopgap (withdrawnRefusal). +func refusal(module string, manifest *solutionhost.ModuleManifest, err error) error { + code := status.Code(err) + if code == codes.FailedPrecondition && withdrawnRefusal(status.Convert(err).Message()) { + return &HostStatusError{Code: code, Reason: solutionhost.ErrWithdrawn, Message: fmt.Sprintf( + "the host refused module %s at generation %d: it still holds the withdrawal of binding %s from earlier in its life and keeps a withdrawal terminal (%v). "+ + "The local authority holds applied deliveries in memory only: stop the run and start it again, and the host accepts the module afresh", module, manifest.Generation, manifest.Binding, err)} + } + return &HostStatusError{Code: code, Message: fmt.Sprintf("the host refused the module manifest of %s through the composition interface: %v", module, err)} } diff --git a/pkg/gitops/composition_delivery_test.go b/pkg/gitops/composition_delivery_test.go index 66578069e..442cb1dd5 100644 --- a/pkg/gitops/composition_delivery_test.go +++ b/pkg/gitops/composition_delivery_test.go @@ -6,7 +6,7 @@ import ( "fmt" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" "net" - "net/http" + "strings" "testing" "github.com/codefly-dev/core/solutionhost" @@ -74,8 +74,8 @@ func TestCompositionDeliveryCarriesTheSignedManifestThroughApplyManifest(t *test } 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" { + receipt, err := send(ctx, delivery, "probe", signed, "delivery-credential") + if err != nil || 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) { @@ -93,14 +93,61 @@ func TestCompositionDeliveryCarriesTheSignedManifestThroughApplyManifest(t *test } manifest.Generation = 1 document, _ = solutionhost.SignedPayload(manifest) - _, err = delivery.Post(ctx, "probe", &solutionhost.Signed{Document: document, Envelope: signed.Envelope}, "delivery-credential") + _, err = send(ctx, delivery, "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) + if !asHostStatus(err, &refusal) || refusal.Code != codes.FailedPrecondition || refusal.Transient() { + t.Fatalf("a lower generation is a final refusal: %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) + _, err = send(ctx, delivery, "probe", signed, "") + if !asHostStatus(err, &refusal) || refusal.Code != codes.PermissionDenied || refusal.Transient() { + t.Fatalf("no credential is a final refusal: %v", err) + } +} + +// send prepares the module's declaration and sends signed with it, as +// NativeDeliverer.Deliver does once the generation is settled. +func send(ctx context.Context, c *CompositionDelivery, 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) +} + +// TestOnlyAHostThatIsNotServingIsRetried: a refusal is the host's answer and +// fails at once, whatever its code — an interface it does not serve, an +// internal failure, a bad request, a denial, a precondition — and only +// unavailability, a deadline or exhausted capacity says to ask again. +func TestOnlyAHostThatIsNotServingIsRetried(t *testing.T) { + for code, transient := range map[codes.Code]bool{ + codes.Unavailable: true, codes.DeadlineExceeded: true, codes.ResourceExhausted: true, + codes.Unimplemented: false, codes.Internal: false, codes.InvalidArgument: false, codes.PermissionDenied: false, + codes.FailedPrecondition: false, codes.NotFound: false, codes.Unknown: false, codes.Aborted: false, + } { + refusal, ok := refusal("probe", &solutionhost.ModuleManifest{Binding: "b", Generation: 2}, status.Error(code, "no")).(*HostStatusError) + if !ok || refusal.Code != code || refusal.Transient() != transient { + t.Fatalf("%s: transient %v, want %v", code, refusal.Transient(), transient) + } + } +} + +// TestAHostHoldingAWithdrawalSaysWhatToDo: the binding is derived, so the +// refusal of a re-added module names the one thing the person can do. +func TestAHostHoldingAWithdrawalSaysWhatToDo(t *testing.T) { + err := refusal("crm", &solutionhost.ModuleManifest{Binding: "b-crm", Generation: 3}, + status.Errorf(codes.FailedPrecondition, "stale: %v: binding \"b-crm\" was withdrawn at generation 2", solutionhost.ErrWithdrawn)) + var held *HostStatusError + if !asHostStatus(err, &held) || held.Transient() { + t.Fatalf("a held withdrawal is a final refusal: %v", err) + } + for _, want := range []string{"binding b-crm", "generation 3", "stop the run and start it again"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("refusal %q does not name %q", err, want) + } } } @@ -156,7 +203,7 @@ func TestCompositionWithdrawalDoesNotReadOrDeliverLivePolicies(t *testing.T) { if err != nil { t.Fatal(err) } - receipt, err := delivery.Post(context.Background(), "probe", signed, "delivery-credential") + receipt, err := send(context.Background(), delivery, "probe", signed, "delivery-credential") if err != nil { t.Fatal(err) } @@ -191,7 +238,7 @@ func TestCompositionDeliveryRefusesAMismatchedAcknowledgement(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = delivery.Post(t.Context(), "probe", &solutionhost.Signed{Document: document, Envelope: []byte(`{"test":true}`)}, "credential") + _, err = send(t.Context(), delivery, "probe", &solutionhost.Signed{Document: document, Envelope: []byte(`{"test":true}`)}, "credential") if err == nil { t.Fatal("wrong generation was treated as applied") } diff --git a/pkg/gitops/hop_routes_test.go b/pkg/gitops/hop_routes_test.go new file mode 100644 index 000000000..38edbb7b9 --- /dev/null +++ b/pkg/gitops/hop_routes_test.go @@ -0,0 +1,163 @@ +package gitops + +import ( + "context" + "encoding/json" + "fmt" + "github.com/codefly-dev/cli/pkg/orchestration" + "github.com/codefly-dev/core/composition" + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/authority/libraries/hop/go/routing" +) + +func hopRouteWorkspace(t *testing.T, visibility string) *resources.Workspace { + t.Helper() + files := map[string]string{ + "workspace.codefly.yaml": "name: routes\nlayout: modules\nmodules:\n - name: platform\n - name: payments\n", + "configurations/local/hop.env": "MODULES=platform\n", + "modules/platform/module.codefly.yaml": "name: platform\nservices:\n - name: first\n - name: second\n", + "modules/platform/module.contract.codefly.yaml": "schema: codefly/module-contract/v1\nprincipal: platform\nnamespaces: [platform]\nqueues: []\nscope_ceilings: []\nbindings: []\n", + "modules/payments/module.codefly.yaml": "name: payments\nservices:\n - name: worker\n", + "modules/payments/services/worker/service.codefly.yaml": "name: worker\nversion: 0.0.0\nagent: {kind: runtime::service, name: go-grpc, version: 0.0.16, publisher: codefly.ai}\nworkspace-configuration-dependencies: [hop-routes]\n", + } + catalog := composition.APIContractCatalog{Schema: composition.APIContractCatalogSchema, Package: "owner/platform", Version: "1.0.0"} + manifest := "schema: codefly/module-package/v2\nkind: module-package\nid: owner/platform\nversion: 1.0.0\nminimum-codefly-version: '>=0.3.32 <1.0.0'\nartifact-roots: [contracts, services]\ncontracts: {composition: '>=2.0.0 <3.0.0'}\nservices:\n" + for _, service := range []string{"first", "second"} { + path := "contracts/api/" + service + ".json" + doc := fmt.Sprintf(`{"openapi":"3.0.0","info":{"title":"%s","version":"1.0.0"},"paths":{"/%s":{"get":{"responses":{"200":{"description":"ok"}}}}}}`, service, service) + digest := composition.APIContractDigest([]byte(doc)) + files["modules/platform/"+path] = doc + files["modules/platform/services/"+service+"/service.codefly.yaml"] = "name: " + service + "\nversion: 0.0.0\nagent: {kind: runtime::service, name: go-grpc, version: 0.0.16, publisher: codefly.ai}\nendpoints:\n - name: web\n api: http\n visibility: " + visibility + "\n" + " exposure: none\n" + manifest += fmt.Sprintf(" - name: %s\n endpoints: [web]\n api-contracts:\n - endpoint: web\n kind: openapi\n package: api\n path: %s\n digest: %s\n", service, path, digest) + catalog.Endpoints = append(catalog.Endpoints, composition.APIContractEndpoint{Service: service, Endpoint: "web", API: "http", Kind: composition.APIContractKindOpenAPI, Package: "api", Path: path, Digest: digest, Routes: []composition.APIContractRoute{{Method: "GET", Path: "/" + service}}}) + } + raw, err := json.Marshal(catalog) + require.NoError(t, err) + files["modules/platform/"+composition.APIContractCatalogFileName] = string(raw) + files["modules/platform/module.package.codefly.yaml"] = manifest + root := t.TempDir() + for rel, raw := range files { + path := filepath.Join(root, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + require.NoError(t, os.WriteFile(path, []byte(raw), 0600)) + } + workspace, err := resources.LoadWorkspaceFromDir(context.Background(), root) + require.NoError(t, err) + return workspace +} + +func TestCellSeesGeneratedHopRoutes(t *testing.T) { + ctx := context.Background() + ws := hopRouteWorkspace(t, "public") + env := selectedEnvironment(t, ws, "local") + provided, _, err := orchestration.WorkspaceConfigurationsForChecking(ctx, ws, env) + require.NoError(t, err) + require.NotEmpty(t, provided.Infos) + consumers, _, err := endpointConsumers(ctx, ws, env) + require.NoError(t, err) + require.Equal(t, []string{"payments/worker"}, consumers["platform/first/web"]) + require.Equal(t, []string{"payments/worker"}, consumers["platform/second/web"]) +} + +// policyRouteWorkspace is a hop composing platform, whose one listener +// publishes doc while its catalog lists routes: the catalog may disagree with +// its document, which no digest binds. +func policyRouteWorkspace(t *testing.T, doc string, routes []composition.APIContractRoute) *resources.Workspace { + t.Helper() + digest := composition.APIContractDigest([]byte(doc)) + catalog := composition.APIContractCatalog{Schema: composition.APIContractCatalogSchema, Package: "owner/platform", Version: "1.0.0", Endpoints: []composition.APIContractEndpoint{ + {Service: "first", Endpoint: "web", API: "http", Kind: composition.APIContractKindOpenAPI, Package: "api", Path: "contracts/api/first.json", Digest: digest, Routes: routes}, + }} + raw, err := json.Marshal(catalog) + require.NoError(t, err) + files := map[string]string{ + "workspace.codefly.yaml": "name: routes\nlayout: modules\nmodules:\n - name: platform\n - name: payments\n", + "configurations/local/hop.env": "MODULES=platform\n", + "modules/platform/module.codefly.yaml": "name: platform\nservices:\n - name: first\n", + "modules/platform/module.contract.codefly.yaml": "schema: codefly/module-contract/v1\nprincipal: platform\nnamespaces: [platform]\nqueues: []\nscope_ceilings: []\nbindings: []\n", + "modules/platform/contracts/api/first.json": doc, + "modules/platform/" + composition.APIContractCatalogFileName: string(raw), + "modules/platform/services/first/service.codefly.yaml": "name: first\nversion: 0.0.0\nagent: {kind: runtime::service, name: go-grpc, version: 0.0.16, publisher: codefly.ai}\nendpoints:\n - name: web\n api: http\n visibility: public\n exposure: none\n", + "modules/platform/module.package.codefly.yaml": "schema: codefly/module-package/v2\nkind: module-package\nid: owner/platform\nversion: 1.0.0\nminimum-codefly-version: '>=0.3.32 <1.0.0'\nartifact-roots: [contracts, services]\ncontracts: {composition: '>=2.0.0 <3.0.0'}\nservices:\n - name: first\n endpoints: [web]\n api-contracts:\n - endpoint: web\n kind: openapi\n package: api\n path: contracts/api/first.json\n digest: " + digest + "\n", + "modules/payments/module.codefly.yaml": "name: payments\nservices:\n - name: worker\n", + "modules/payments/services/worker/service.codefly.yaml": "name: worker\nversion: 0.0.0\nagent: {kind: runtime::service, name: go-grpc, version: 0.0.16, publisher: codefly.ai}\nworkspace-configuration-dependencies: [hop-routes]\n", + } + root := t.TempDir() + for rel, content := range files { + path := filepath.Join(root, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) + } + workspace, err := resources.LoadWorkspaceFromDir(context.Background(), root) + require.NoError(t, err) + return workspace +} + +// hopRoutesOf is the ROUTES snapshot a hop of the workspace is handed. +func hopRoutesOf(t *testing.T, workspace *resources.Workspace) *routing.Routing { + t.Helper() + provided, _, err := orchestration.WorkspaceConfigurationsForChecking(context.Background(), workspace, selectedEnvironment(t, workspace, "local")) + require.NoError(t, err) + for _, info := range provided.Infos { + if info.GetName() != "hop-routes" { + continue + } + for _, value := range info.GetConfigurationValues() { + if value.GetKey() == "ROUTES" { + routes, err := routing.DecodeRouting(value.GetValue()) + require.NoError(t, err) + return routes + } + } + } + t.Fatal("no hop-routes/ROUTES generated") + return nil +} + +// The hop maps a call onto a route the authority holds a policy for, read +// from the one table delivered to it: a catalog that omits a stricter route +// its document declares cannot make the hop check that call against a looser +// template. Every listener's ROUTES methods are its delivered policy methods, +// basePath included. +func TestHopRoutesAreTheDeliveredPolicyTable(t *testing.T) { + const policies = `"/v1/things/{id}":{"get":{"x-codefly-authority-policy":{"anyCheckedCaller":true},"responses":{"200":{"description":"ok"}}}},` + + `"/v1/things/export":{"get":{"x-codefly-authority-policy":{"requires":[{"resourceKind":"thing","actions":["export"]}]},"responses":{"200":{"description":"ok"}}}}` + for _, tc := range []struct { + name, doc, call, template string + }{ + {"openapi 3", `{"openapi":"3.0.0","info":{"title":"api","version":"1.0.0"},"paths":{` + policies + `}}`, "GET /v1/things/export", "GET /v1/things/export"}, + {"swagger basePath", `{"swagger":"2.0","info":{"title":"api","version":"1.0.0"},"basePath":"/api","paths":{` + policies + `}}`, "GET /api/v1/things/export", "GET /api/v1/things/export"}, + } { + t.Run(tc.name, func(t *testing.T) { + // The catalog lists only the looser route, with the right digest. + workspace := policyRouteWorkspace(t, tc.doc, []composition.APIContractRoute{{Method: "GET", Path: "/v1/things/{id}"}}) + routes := hopRoutesOf(t, workspace) + _, template, err := routes.Resolve("platform", "http", tc.call) + require.NoError(t, err) + require.Equal(t, tc.template, template, "the hop checks the stricter route's own policy") + + module, err := workspace.LoadModuleFromName(context.Background(), "platform") + require.NoError(t, err) + delivered, ok, err := MethodPolicies(module.Dir()) + require.NoError(t, err) + require.True(t, ok) + var want []string + for _, m := range delivered { + want = append(want, m.GetMethod()) + } + require.Len(t, routes.Endpoints, 1) + require.Equal(t, want, routes.Endpoints[0].Methods, "ROUTES is the delivered table, spelled the same way") + for _, m := range delivered { + if strings.HasSuffix(m.GetMethod(), "/export") { + require.Equal(t, "thing", m.GetRequires()[0].GetResourceKind()) + } + } + }) + } +} diff --git a/pkg/gitops/method_policies.go b/pkg/gitops/method_policies.go index 8241f88b2..467ea256e 100644 --- a/pkg/gitops/method_policies.go +++ b/pkg/gitops/method_policies.go @@ -1,27 +1,19 @@ 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" + "github.com/codefly-dev/cli/pkg/methodpolicy" ) // MethodPolicies reads what each method a module's published protobuf contracts @@ -98,7 +90,7 @@ func ServiceMethodPolicies(dir string) (served ServedMethodPolicies, ok bool, er if err != nil { return served, false, err } - catalog, err := loadServedCatalog(dir) + catalog, err := methodpolicy.LoadCatalog(dir) if err != nil { return served, false, err } @@ -108,26 +100,13 @@ func ServiceMethodPolicies(dir string) (served ServedMethodPolicies, ok bool, er 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 - } + methods, skipped, err := methodpolicy.Contract(dir, catalog, service.Name, &contract) if err != nil { return served, false, fmt.Errorf("module package %s, service %s: %w", manifest.ID, service.Name, err) } + for _, method := range skipped { + unserved[method] = true + } for _, m := range methods { if previous := seen[m.GetMethod()]; previous == nil { seen[m.GetMethod()] = m @@ -164,47 +143,6 @@ func ServiceMethodPolicies(dir string) (served ServedMethodPolicies, ok bool, er 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. @@ -227,189 +165,6 @@ func foreignMethods(dir string) ([]*compositionv1.MethodPolicyV1, error) { 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, "