Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions pkg/gitops/cell.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@
result.Skipped = append(result.Skipped, inventory.Module)
continue
}
if err := refuseUncomposedRender(workspace, env, tree, &inventory); err != nil {

Check failure on line 159 in pkg/gitops/cell.go

View workflow job for this annotation

GitHub Actions / lint

shadow: declaration of "err" shadows declaration at line 113 (govet)
return cellfile.File{}, CellResult{}, err
}
// A tree rendered before the environment excluded one of its services
// still delivers it; describing it would put back what the
// composition withdrew.
Expand Down Expand Up @@ -226,6 +229,72 @@
env.Name, env.Name, strings.Join(missing, ", "), strings.Join(commands, "; "), env.Name)
}

// refuseUncomposedRender refuses a tree rendered for this environment whose
// module the workspace no longer composes: a solution withdrawn from the
// composition, or a module nothing pins any more. Its render still stands
// under the modules directory, so without this it would be described into
// the cell file as if it were composed — or, when the cell file is derived
// without it, the tree's pods are refused by the cell's admission while the
// delivery still runs them, each refusal reading as an application fault.
// The converse hold is refuseUnrenderedModules: together they make the
// rendered trees and the composed modules the same set. The tree is named
// for removal rather than skipped, because a skipped tree is still delivered.
func refuseUncomposedRender(workspace *resources.Workspace, env *environments.Environment, tree string, inventory *Inventory) error {
for _, reference := range workspace.Modules {
if reference != nil && reference.Name == inventory.Module {
return nil
}
}
return fmt.Errorf("environment %s's cell file is not written: %s holds a render of module %s for %s, but the workspace composes no module of that name, so nothing approves what that tree delivers (the cell refuses its pods while the delivery still runs them); remove that render and the module's delivered tree, or compose the module again",
env.Name, tree, inventory.Module, env.Name)
}

// meshOptOutLabels and meshOptOutAnnotations are the pod-template fields that
// take a pod out of ambient capture: istio.io/dataplane-mode and
// sidecar.istio.io/inject as labels; ambient.istio.io/bypass-inbound-capture
// and sidecar.istio.io/status (which istio-cni reads as already injected) as
// annotations. The cell's execution-admission policy refuses a pod carrying
// any of them, because STRICT mTLS and every allow derived from the cell file
// stop applying to a pod ztunnel does not capture.
var (
meshOptOutLabels = []string{"istio.io/dataplane-mode", "sidecar.istio.io/inject"}
meshOptOutAnnotations = []string{"ambient.istio.io/bypass-inbound-capture", "sidecar.istio.io/status"}
)

// refuseUnadmittedExecution refuses a rendered workload the cell's
// execution-admission policy would refuse on its pod template alone — before
// any digest is compared, so before the cell file approves it. Two of the
// policy's rules read only the template: no container may declare an exec
// lifecycle hook or an exec probe (a command of the delivering party's
// choosing, run inside the pod that already holds the workload identity: the
// build binding undone one field further down from the image), and no pod may
// opt out of the mesh. The cell file this render derives is what generates
// that policy, so a workload it would describe and the policy would refuse is
// a workload with zero replicas and a refusal that reads as an application
// fault; it is refused here, by container and field.
func refuseUnadmittedExecution(module, unit string, rendered *renderedWorkload) error {
for _, containers := range [][]renderedContainer{rendered.Containers, rendered.InitContainers} {
for _, container := range containers {
if len(container.execs) == 0 {
continue
}
return fmt.Errorf("unit %s/%s workload %s: container %s declares %s, which runs a command inside the pod that holds the workload identity; the cell's execution admission refuses such a pod, so the cell file does not approve it. Use an HTTP, TCP or gRPC probe, and put start-up work in the approved image",
module, unit, rendered.Name, container.Name, strings.Join(container.execs, ", "))
}
}
for _, label := range meshOptOutLabels {
if _, set := rendered.templateLabels[label]; set {
return fmt.Errorf("unit %s/%s workload %s: its pod template carries the %s label, which opts the pod out of the mesh; the cell enrols the namespace and refuses a pod that overrides it, so the cell file does not approve it", module, unit, rendered.Name, label)
}
}
for _, annotation := range meshOptOutAnnotations {
if _, set := rendered.templateAnnotations[annotation]; set {
return fmt.Errorf("unit %s/%s workload %s: its pod template carries the %s annotation, which opts the pod out of the mesh; the cell enrols the namespace and refuses a pod that overrides it, so the cell file does not approve it", module, unit, rendered.Name, annotation)
}
}
return nil
}

// writeCell encodes the cell file and writes it at path.
func writeCell(file *cellfile.File, path string) error {
body, err := file.Encode()
Expand Down Expand Up @@ -362,6 +431,9 @@
if reserved := refuseReservedIdentity(inventory.Module, unit.Name, rendered, selector); reserved != nil {
return cellfile.Namespace{}, reserved
}
if unadmitted := refuseUnadmittedExecution(inventory.Module, unit.Name, rendered); unadmitted != nil {
return cellfile.Namespace{}, unadmitted
}
authenticating, err := authenticatingContainer(unit.Name, rendered)
if err != nil {
return cellfile.Namespace{}, fmt.Errorf("unit %s/%s workload %s: %w", inventory.Module, unit.Name, rendered.Name, err)
Expand Down
110 changes: 110 additions & 0 deletions pkg/gitops/cell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -694,3 +694,113 @@ func TestTheCellNamesTheDeliveryEndpointAndNotOnlyItsWorkload(t *testing.T) {
})
}
}

// A container declaring an exec probe or an exec lifecycle hook is refused by
// name and field before the cell file approves it: the cell's execution
// admission refuses such a pod (a command of the delivering party's choosing
// runs inside the pod that holds the workload identity), so a cell file that
// described it would approve a workload with zero running replicas. An HTTP
// probe on the same container is admitted.
func TestCellFileRefusesAnExecProbeTheCellWouldRefuse(t *testing.T) {
workspace := writeCellWorkspace(t)
env := selectedEnvironment(t, workspace, "staging")
renderCellTree(t, workspace, "billing", "ledger", "acme-billing", "staging")
result := renderCellTree(t, workspace, "shop", "api", "acme-shop", "staging")
rendered := filepath.Join(result.Path, "services", "api", "overlays", "staging", "deployment.yaml")
data, err := os.ReadFile(rendered)
require.NoError(t, err)
const image = "image: registry.example.test/acme/api:1.2.0@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
require.Contains(t, string(data), image)

for _, probe := range []struct{ field, yaml string }{
{"livenessProbe.exec", "livenessProbe:\n exec:\n command: [python3, -m, api.server, --health]"},
{"startupProbe.exec", "startupProbe:\n exec:\n command: [true]"},
{"lifecycle.postStart.exec", "lifecycle:\n postStart:\n exec:\n command: [true]"},
} {
require.NoError(t, os.WriteFile(rendered, []byte(strings.Replace(string(data), image, image+"\n "+probe.yaml, 1)), 0o644))
_, err = RenderCell(context.Background(), workspace, env)
require.Error(t, err, probe.field)
require.Contains(t, err.Error(), "unit shop/api workload api: container api declares "+probe.field)
require.Contains(t, err.Error(), "Use an HTTP, TCP or gRPC probe")
}

httpProbe := "livenessProbe:\n httpGet:\n path: /healthz\n port: http"
require.NoError(t, os.WriteFile(rendered, []byte(strings.Replace(string(data), image, image+"\n "+httpProbe, 1)), 0o644))
_, err = RenderCell(context.Background(), workspace, env)
require.NoError(t, err, "an HTTP probe is admitted")
}

// A pod template that opts out of the mesh is refused: the cell enrols the
// namespace and its admission refuses a pod that overrides it, so the file
// does not approve one.
func TestCellFileRefusesAPodThatOptsOutOfTheMesh(t *testing.T) {
workspace := writeCellWorkspace(t)
env := selectedEnvironment(t, workspace, "staging")
renderCellTree(t, workspace, "billing", "ledger", "acme-billing", "staging")
result := renderCellTree(t, workspace, "shop", "api", "acme-shop", "staging")
rendered := filepath.Join(result.Path, "services", "api", "overlays", "staging", "deployment.yaml")
data, err := os.ReadFile(rendered)
require.NoError(t, err)
const templateLabels = " labels:\n app.kubernetes.io/name: api\n"
require.Contains(t, string(data), templateLabels)

require.NoError(t, os.WriteFile(rendered, []byte(strings.Replace(string(data), templateLabels, templateLabels+" istio.io/dataplane-mode: none\n", 1)), 0o644))
_, err = RenderCell(context.Background(), workspace, env)
require.ErrorContains(t, err, "unit shop/api workload api: its pod template carries the istio.io/dataplane-mode label, which opts the pod out of the mesh")

require.NoError(t, os.WriteFile(rendered, []byte(strings.Replace(string(data), templateLabels, templateLabels+" annotations:\n ambient.istio.io/bypass-inbound-capture: \"true\"\n", 1)), 0o644))
_, err = RenderCell(context.Background(), workspace, env)
require.ErrorContains(t, err, "carries the ambient.istio.io/bypass-inbound-capture annotation")

// A CronJob's pod template stands under spec.jobTemplate.spec.template;
// an opt-out there is read where it stands.
cronJob := `---
apiVersion: batch/v1
kind: CronJob
metadata:
name: api-sweep
spec:
schedule: "0 * * * *"
jobTemplate:
spec:
template:
metadata:
labels:
codefly.dev/bootstrap-service: api
sidecar.istio.io/inject: "false"
spec:
serviceAccountName: api
restartPolicy: OnFailure
containers:
- name: api
image: registry.example.test/acme/api-sweep@sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
`
require.NoError(t, os.WriteFile(rendered, append([]byte(data), []byte(cronJob)...), 0o644))
_, err = RenderCell(context.Background(), workspace, env)
require.ErrorContains(t, err, "workload api-sweep: its pod template carries the sidecar.istio.io/inject label")
}

// A tree rendered for the environment whose module the workspace no longer
// composes — a solution withdrawn from the composition, a module nothing pins
// — refuses the cell file by name, with the tree to remove: described, it
// would approve a workload the composition does not declare; skipped, its
// pods would be refused by the cell while the delivery still runs them.
// Removed, the file is written as before.
func TestCellFileRefusesATreeOfAModuleTheWorkspaceDoesNotCompose(t *testing.T) {
workspace := writeCellWorkspace(t)
env := selectedEnvironment(t, workspace, "staging")
renderCellTree(t, workspace, "shop", "api", "acme-shop", "staging")
renderCellTree(t, workspace, "billing", "ledger", "acme-billing", "staging")
withdrawn := renderCellTree(t, workspace, "proposals", "api", "acme-proposals", "staging")

_, err := RenderCell(context.Background(), workspace, env)
require.Error(t, err)
require.Contains(t, err.Error(), "cell file is not written")
require.Contains(t, err.Error(), withdrawn.Path+" holds a render of module proposals for staging, but the workspace composes no module of that name")
require.Contains(t, err.Error(), "remove that render and the module's delivered tree")

require.NoError(t, os.RemoveAll(withdrawn.Path))
again, err := RenderCell(context.Background(), workspace, env)
require.NoError(t, err)
require.Equal(t, []string{"billing", "shop"}, again.Modules)
}
9 changes: 8 additions & 1 deletion pkg/gitops/container_ports_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,14 @@ func treeDigest(t *testing.T, root string) string {
// place of the presence, authority and endpoint documents): the fixture
// declares no manifest, and the digest moves with the schemaVersion alone —
// verified by rendering it at schema 7, which reproduces the previous digest.
const undeclaredFixtureDigest = "49cb877e08a7a2079deea3c862b33ad5e592ede4d449aebc02682ede9f60021f"
//
// And once more when the inventory started recording the environment
// declaration digest (environmentDeclarationDigest: the service-config and
// service-identity rows of the module's services, which a publish holds the
// tree to). The fixture declares no such row, so the stamp is the digest of an
// empty declaration; the manifests are unchanged and the two cases below still
// agree, which is the property this digest pins.
const undeclaredFixtureDigest = "8bb67b42d7ab8a8a9a0c05ee0023ad273e5ef32278efe8a1b6a30808d7687df8"

func TestRenderModuleWithoutDeclarationsIsByteIdentical(t *testing.T) {
installFakeAgents(t)
Expand Down
87 changes: 87 additions & 0 deletions pkg/gitops/environment_declaration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package gitops

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"

"github.com/codefly-dev/cli/pkg/environments"
)

// --- The environment declaration a render bakes in ---
//
// Beside the workspace configuration groups (configuration_groups.go), a
// render bakes in the environment's own rows for the module's services: the
// service-config values it injects into each container, and the
// service-identity each workload runs as. Change those rows and publish
// nothing, and the delivery branch holds a tree rendered from a declaration
// that no longer exists, with nothing in the tree saying so: the workload keeps
// running on the value before the change and looks correctly configured.
//
// So every render records a digest of those rows as the environment declared
// them, over the services its inventory names, and a publish re-digests the
// same services against the environment as it is now and refuses a tree the
// declaration has moved past. The services are read from the inventory at
// both ends, so the two digests cover one set and a render is never refused
// for a service the publish could not see.

// serviceUnitNames is the name of every unit of Kind service an inventory
// records, managed ones (Kind service, Managed true) included: a managed
// service's identity rows are declared the same way, and a unit with no rows
// digests the same at both ends. Solution units are not among them.
func serviceUnitNames(units []InventoryUnit) []string {
var names []string
for _, unit := range units {

Check failure on line 36 in pkg/gitops/environment_declaration.go

View workflow job for this annotation

GitHub Actions / lint

rangeValCopy: each iteration copies 128 bytes (consider pointers or indexing) (gocritic)
if unit.Kind == UnitKindService && unit.Name != "" {
names = append(names, unit.Name)
}
}
sort.Strings(names)
return names
}

// environmentDeclarationDigest digests, for the named services of a module,
// the environment's service-config values and service-identity as it declares
// them now. The result is a sha256 over one line per declared row, sorted, so
// two declarations that differ only in order digest the same.
func environmentDeclarationDigest(env *environments.Environment, module string, services []string) string {
var lines []string
for _, service := range services {
for key, value := range env.ServiceConfig.ValuesFor(module, service) {
lines = append(lines, fmt.Sprintf("service-config %s/%s %s=%s", module, service, key, value))
}
identity := env.WorkloadIdentityFor(module, service)
if identity == nil {
continue
}
lines = append(lines, fmt.Sprintf("service-identity %s/%s kind=%s principal=%s", module, service, identity.Kind, identity.Principal))
for key, value := range identity.Annotations {
lines = append(lines, fmt.Sprintf("service-identity %s/%s annotation %s=%s", module, service, key, value))
}
for key, value := range identity.Labels {
lines = append(lines, fmt.Sprintf("service-identity %s/%s label %s=%s", module, service, key, value))
}
}
sort.Strings(lines)
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
return "sha256:" + hex.EncodeToString(sum[:])
}

// refuseStaleDeclaration refuses to publish a tree whose recorded environment
// declaration digest is not the environment's current one for the same
// services. A tree with no recorded digest was rendered by a CLI that did not
// record one and is held to nothing here: the hold cannot say what that
// render baked in, and the next render records it.
func refuseStaleDeclaration(env *environments.Environment, inventory *Inventory) error {
if env == nil || inventory == nil || inventory.EnvironmentDeclarationDigest == "" {
return nil
}
current := environmentDeclarationDigest(env, inventory.Module, serviceUnitNames(inventory.Units))
if current == inventory.EnvironmentDeclarationDigest {
return nil
}
return fmt.Errorf("the rendered tree of %s was rendered before environment %s's declaration for its services changed (service-config or service-identity rows of %s); render %s again for %s before publishing it",
inventory.Module, env.Name, strings.Join(serviceUnitNames(inventory.Units), ", "), inventory.Module, env.Name)
}
Loading
Loading