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
3 changes: 3 additions & 0 deletions .github/workflows/pr-workflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# limitations under the License.

name: pr-workflow
on:

Check warning on line 16 in .github/workflows/pr-workflow.yaml

View workflow job for this annotation

GitHub Actions / run-tests

16:1 [truthy] truthy value should be one of [false, true]
pull_request:
merge_group:
push:
Expand Down Expand Up @@ -146,6 +146,9 @@
# E2E_JUNIT_FILE is set per step, not here: the lanes below share this
# job, so one job-level path would have each overwrite the last.
ARTIFACTS: ${{ github.workspace }}/_artifacts
# The NFS driver is installed below, so the existingvolumes suite fails
# rather than skips without it.
E2E_CSI_NFS: "1"
steps:
- name: Checkout
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
Expand Down
2 changes: 2 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ Everything on `giantswarm` that is not in the pin (`git log v0.4.0-alpha1..giant
| The egress gateway's ext-proc names its telemetry `atenet-egress` (`OTEL_SERVICE_NAME` on the `atenet-egress` `ext-proc` container; a chart unit test) | the `atenet` binary hardcodes the service name `atenet-router` for both modes and the container set none, so egress spans and logs carried `service.name=atenet-router` | `6627a636` ([#217](https://github.com/giantswarm/substrate/pull/217), rebase-merged 2026-10-09; first release 1.6.2) | fork-only chart change; upstream's `atenet-egress.yaml` has the same gap, not queued |
| The gVisor SandboxConfig's pause image is a chart value (`images.pause`, default upstream's `registry.k8s.io/pause:3.10.2@sha256:f548e0e8…`, rendered with `required`; the unit-test suite `charts/substrate/tests/sandboxconfig_gvisor_test.yaml` covers the default, an override and the empty value; the README row; the preserved kubectl-apply manifest keeps upstream's default) | the one image reference the chart hardcoded, and the one an operator could not move: `registry.k8s.io` redirects every pull to a Google Artifact Registry host, so a cluster whose egress admits only its own registry failed every golden boot while creating the pause OCI bundle, before any snapshot existed ([#224](https://github.com/giantswarm/substrate/issues/224)) | `f844d726` ([#225](https://github.com/giantswarm/substrate/pull/225), rebase-merged 2026-10-09; first release 1.6.3) | to file (prepared): branch [`upstream/sandbox-pause-image-value`](https://github.com/giantswarm/substrate/tree/upstream/sandbox-pause-image-value) here (`1591d20d`, the same commit on kagent-dev `main` @ `9c4b1fb5` of 2026-10-08, DCO signed), the shape of [kagent-dev/substrate#23](https://github.com/kagent-dev/substrate/pull/23) (2026-08-20, closed unmerged 2026-10-06 on a stale base); [#37742](https://github.com/giantswarm/giantswarm/issues/37742) row 155 |
| The pause image defaults to its gsoci copy: `images.pause` = `gsoci.azurecr.io/giantswarm/pause:3.10.2@sha256:f548e0e8…`, upstream's digest | every Giant Swarm installation pulls from gsoci.azurecr.io, and one whose egress admits only that registry cannot reach `registry.k8s.io`; the same digest, so the snapshots that record it restore unchanged ([#224](https://github.com/giantswarm/substrate/issues/224)) | `dd957c2e` ([#225](https://github.com/giantswarm/substrate/pull/225), rebase-merged 2026-10-09; first release 1.6.3) | **ours to keep**: a Giant Swarm registry is not upstream's default; the upstream-shaped change is the row above |
| `resources.DeepEqual`, the unchanged-value check of the generated declarative validation, compares a slice of proto messages (a repeated message field) element by element with `proto.Equal`, a nil and an empty one being equal | it handed the slice to `reflect.DeepEqual`, which also compares each message's internal state; the RPC logger's marshal fills the size cache, so a repeated message field and its clone differed and an `+k8s:immutable` repeated field (`Actor.existing_volumes`, row below) failed every `CreateActor` with "field is immutable" | [#234](https://github.com/giantswarm/substrate/pull/234) (`fix(resources): compare repeated message fields with proto.Equal`) | none: upstream `main` has the same `DeepEqual`; to file, queued in the upstream engagement list |
| An existing volume mounted per actor at a sub-path: an ActorTemplate declares it (`Volume.existing_volume`, field 10001, an empty marker), `CreateActor` supplies it (`Actor.existing_volumes`, field 10001, immutable; `ExistingVolume` = name, CSI driver, volume handle, access mode `READ_WRITE_MANY`/`READ_ONLY_MANY` in the shape of upstream's `VolumeAccessMode`, and the `sub_path` the actor sees as the volume's root), and `VolumeMount` gains `sub_path` and `read_only` (fields 10001, 10002), so one volume mounts at several paths. `CreateActor` refuses a reference to a volume the template does not declare as existing, a driver without a `CSIDriverConfig`, a handle no PersistentVolume of the driver holds and an access mode the PersistentVolume does not permit (ate-api-server reads PersistentVolumes: a new ClusterRole rule); the PersistentVolume's `volumeAttributes` reach the driver at resume. An unsupplied existing volume contributes neither itself nor its mounts. Substrate never creates, deletes or detaches one: pause, resume and delete only unmount and mount it; a multi-node volume is staged per target, and atelet binds each sub-path from a descriptor opened beneath the volume's root without following symbolic links (`openat2` `RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS`), so a missing directory or a planted link fails the mount. An actor with existing volumes boots from its image instead of the golden snapshot and cannot be cloned from a tag. Tests: unit (validation, workload spec, CSI staging, the sub-path resolver), the `existingvolumes` e2e on kind with the CSI NFS driver (`E2E_CSI_NFS=1` in pr-workflow) | Sessions on one workspace each need their own directory of one read-write-many volume read-write and its git mirrors read-only, hundreds a day; a per-actor volume or snapshot per Session does not scale, and an external volume was created per actor, attached single-node-writer and deleted with it, with no sub-path or read-only mount | [#234](https://github.com/giantswarm/substrate/pull/234) (`feat: mount an existing volume per actor at a sub-path`) | follows agent-substrate/substrate#1637 (shared volumes across actors, open), in the shape proposed there (https://github.com/agent-substrate/substrate/issues/1637#issuecomment-6083442678), on top of #1988's access modes (open); upstream puts it behind the Preview gate of #1994 (open), which this line does not carry, so it is ungated here. [#37742](https://github.com/giantswarm/giantswarm/issues/37742) row 157. Exit: drops at the re-pin that carries #1637's API |

Twenty-three patches change Substrate ahead of upstream — egress for an actor while it resumes, without which no
skill-carrying agent of the platform boots, the atelet scheduling knobs, the keep policy on the CRD chart's
Expand Down
5 changes: 5 additions & 0 deletions charts/substrate/templates/ate-api-server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ rules:
- apiGroups: ["storage.k8s.io"]
resources: ["storageclasses"]
verbs: ["get", "watch", "list"]
# PersistentVolumes: an actor's existing volumes are checked against them at
# CreateActor, and their volume attributes are read at resume.
- apiGroups: [""]
resources: ["persistentvolumes"]
verbs: ["get", "watch", "list"]
# Secret reads for env source resolution are intentionally NOT granted
# cluster-wide here. Each demo / tenant is responsible for granting
# ate-api-server read access only to the specific Secrets referenced by its
Expand Down
18 changes: 18 additions & 0 deletions cmd/ateapi/internal/apivalidation/actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,21 @@ func ValidateCustom_ExternalVolume_StorageVolumeId(_ context.Context, _ operatio
}
return nil
}

// ValidateCustom_ExistingVolume_Driver checks an existing volume's driver
// with the syntax of ExternalVolume.volume_type.
func ValidateCustom_ExistingVolume_Driver(ctx context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *string) field.ErrorList {
return ValidateCustom_ExternalVolume_VolumeType(ctx, op, fldPath, value, oldValue)
}

// ValidateCustom_ExistingVolume_VolumeHandle checks that an existing
// volume's handle, like a storage volume ID, contains no control characters.
func ValidateCustom_ExistingVolume_VolumeHandle(ctx context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *string) field.ErrorList {
return ValidateCustom_ExternalVolume_StorageVolumeId(ctx, op, fldPath, value, oldValue)
}

// ValidateCustom_ExistingVolume_SubPath checks an existing volume's root
// with the shape of VolumeMount.sub_path.
func ValidateCustom_ExistingVolume_SubPath(ctx context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *string) field.ErrorList {
return ValidateCustom_VolumeMount_SubPath(ctx, op, fldPath, value, oldValue)
}
47 changes: 40 additions & 7 deletions cmd/ateapi/internal/apivalidation/actor_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,12 @@ func ValidateActorTemplateUpdate(ctx context.Context, fldPath *field.Path, newVa
}

// ValidateCustom_CreateActorTemplateRequest_ActorTemplate rejects container
// volume mounts that reference volumes the template does not declare.
// volume mounts that reference volumes the template does not declare, and a
// sub_path or read_only on a mount of any volume but an existing one.
func ValidateCustom_CreateActorTemplateRequest_ActorTemplate(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *ateapipb.ActorTemplate) field.ErrorList {
declared := make(map[string]bool, len(value.GetVolumes()))
declared := make(map[string]*ateapipb.Volume, len(value.GetVolumes()))
for _, vol := range value.GetVolumes() {
declared[vol.GetName()] = true
declared[vol.GetName()] = vol
}
var errs field.ErrorList
for i, ctr := range value.GetContainers() {
Expand All @@ -75,10 +76,20 @@ func ValidateCustom_CreateActorTemplateRequest_ActorTemplate(_ context.Context,
if name == "" {
continue // required is enforced by tags
}
if !declared[name] {
errs = append(errs, field.Invalid(
fldPath.Child("containers").Index(i).Child("volume_mounts").Index(j).Child("name"),
name, "must reference a volume declared in the template"))
mountPath := fldPath.Child("containers").Index(i).Child("volume_mounts").Index(j)
vol, ok := declared[name]
if !ok {
errs = append(errs, field.Invalid(mountPath.Child("name"), name, "must reference a volume declared in the template"))
continue
}
if vol.GetExistingVolume() != nil {
continue
}
if mount.GetSubPath() != "" {
errs = append(errs, field.Invalid(mountPath.Child("sub_path"), mount.GetSubPath(), "may be set only on a mount of an existing volume"))
}
if mount.GetReadOnly() {
errs = append(errs, field.Invalid(mountPath.Child("read_only"), true, "may be set only on a mount of an existing volume"))
}
}
}
Expand Down Expand Up @@ -288,3 +299,25 @@ func ValidateCustom_Capabilities_Add(_ context.Context, _ operation.Operation, f
func ValidateCustom_Capabilities_Drop(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ []string) field.ErrorList {
return validateCapabilities(fldPath, value, true)
}

// ValidateCustom_VolumeMount_SubPath requires a clean relative Unix path: no
// leading '/', no '.' or '..' segments, '//', trailing '/', or control
// characters.
func ValidateCustom_VolumeMount_SubPath(_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *string) field.ErrorList {
p := *value
if p == "" {
return nil
}
bad := strings.HasPrefix(p, "/") || strings.HasSuffix(p, "/") ||
strings.Contains(p, "//") || mountPathBadSegmentRE.MatchString(p)
for _, r := range p {
if r < 0x20 || r == 0x7f {
bad = true
break
}
}
if bad {
return field.ErrorList{field.Invalid(fldPath, p, "must be a clean relative Unix path: must not start or end with '/', and contain no '..', '.', '//', or control characters")}
}
return nil
}
62 changes: 62 additions & 0 deletions cmd/ateapi/internal/apivalidation/actor_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,68 @@ func TestValidateCreateActorTemplateRequest(t *testing.T) {
tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "ghost-vol", MountPath: "/var/data"}}
})},
field.ErrorList{field.Invalid(field.NewPath("actor_template", "containers").Index(0).Child("volume_mounts").Index(0).Child("name"), "ghost-vol", "")},
}, {
"existing volume mounted twice, at a sub-path and read-only",
&ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "workspace", ExistingVolume: &ateapipb.ExistingVolumeSource{}}}
tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{
{Name: "workspace", MountPath: "/workspace", SubPath: "sessions/a"},
{Name: "workspace", MountPath: "/mirrors", SubPath: "mirrors", ReadOnly: true},
}
})},
nil,
}, {
"sub_path and read_only on a mount of another kind of volume",
&ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "data", DurableDir: &ateapipb.DurableDirVolumeSource{}}}
tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "data", MountPath: "/var/data", SubPath: "a", ReadOnly: true}}
})},
field.ErrorList{
field.Invalid(field.NewPath("actor_template", "containers").Index(0).Child("volume_mounts").Index(0).Child("sub_path"), "a", ""),
field.Invalid(field.NewPath("actor_template", "containers").Index(0).Child("volume_mounts").Index(0).Child("read_only"), true, ""),
},
}, {
"sub_path '/abs'",
&ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "workspace", ExistingVolume: &ateapipb.ExistingVolumeSource{}}}
tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "workspace", MountPath: "/workspace", SubPath: "/abs"}}
})},
field.ErrorList{field.Invalid(field.NewPath("actor_template", "containers").Index(0).Child("volume_mounts").Index(0).Child("sub_path"), nil, "")},
}, {
"sub_path 'a/'",
&ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "workspace", ExistingVolume: &ateapipb.ExistingVolumeSource{}}}
tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "workspace", MountPath: "/workspace", SubPath: "a/"}}
})},
field.ErrorList{field.Invalid(field.NewPath("actor_template", "containers").Index(0).Child("volume_mounts").Index(0).Child("sub_path"), nil, "")},
}, {
"sub_path 'a//b'",
&ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "workspace", ExistingVolume: &ateapipb.ExistingVolumeSource{}}}
tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "workspace", MountPath: "/workspace", SubPath: "a//b"}}
})},
field.ErrorList{field.Invalid(field.NewPath("actor_template", "containers").Index(0).Child("volume_mounts").Index(0).Child("sub_path"), nil, "")},
}, {
"sub_path 'a/../b'",
&ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "workspace", ExistingVolume: &ateapipb.ExistingVolumeSource{}}}
tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "workspace", MountPath: "/workspace", SubPath: "a/../b"}}
})},
field.ErrorList{field.Invalid(field.NewPath("actor_template", "containers").Index(0).Child("volume_mounts").Index(0).Child("sub_path"), nil, "")},
}, {
"sub_path '.'",
&ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "workspace", ExistingVolume: &ateapipb.ExistingVolumeSource{}}}
tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "workspace", MountPath: "/workspace", SubPath: "."}}
})},
field.ErrorList{field.Invalid(field.NewPath("actor_template", "containers").Index(0).Child("volume_mounts").Index(0).Child("sub_path"), nil, "")},
}, {
"sub_path 'a\\x01b'",
&ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) {
tmpl.Volumes = []*ateapipb.Volume{{Name: "workspace", ExistingVolume: &ateapipb.ExistingVolumeSource{}}}
tmpl.Containers[0].VolumeMounts = []*ateapipb.VolumeMount{{Name: "workspace", MountPath: "/workspace", SubPath: "a\x01b"}}
})},
field.ErrorList{field.Invalid(field.NewPath("actor_template", "containers").Index(0).Child("volume_mounts").Index(0).Child("sub_path"), nil, "")},
}, {
"missing snapshot_config",
&ateapipb.CreateActorTemplateRequest{ActorTemplate: validActorTemplate(func(tmpl *ateapipb.ActorTemplate) {
Expand Down
60 changes: 60 additions & 0 deletions cmd/ateapi/internal/apivalidation/actor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,48 @@ func TestValidateCreateActorRequest(t *testing.T) {
"invalid actor.source_tag.name",
validReq(validActor(withSourceTag("as", "invalid value"))),
field.ErrorList{field.Invalid(field.NewPath("actor", "source_tag", "name"), nil, "").WithOrigin("format=k8s-short-name")},
}, {
"valid existing volume",
validReq(validActor(withExistingVolume(nil))),
nil,
}, {
"existing volume without a driver",
validReq(validActor(withExistingVolume(func(ev *ateapipb.ExistingVolume) { ev.Driver = "" }))),
field.ErrorList{field.Required(field.NewPath("actor", "existing_volumes").Index(0).Child("driver"), "")},
}, {
"existing volume with an invalid driver",
validReq(validActor(withExistingVolume(func(ev *ateapipb.ExistingVolume) { ev.Driver = "Not A Driver" }))),
field.ErrorList{field.Invalid(field.NewPath("actor", "existing_volumes").Index(0).Child("driver"), "Not A Driver", "")},
}, {
"existing volume without a handle",
validReq(validActor(withExistingVolume(func(ev *ateapipb.ExistingVolume) { ev.VolumeHandle = "" }))),
field.ErrorList{field.Required(field.NewPath("actor", "existing_volumes").Index(0).Child("volume_handle"), "")},
}, {
"existing volume handle with a control character",
validReq(validActor(withExistingVolume(func(ev *ateapipb.ExistingVolume) { ev.VolumeHandle = "a\x01b" }))),
field.ErrorList{field.Invalid(field.NewPath("actor", "existing_volumes").Index(0).Child("volume_handle"), "a\x01b", "")},
}, {
"existing volume read-only many",
validReq(validActor(withExistingVolume(func(ev *ateapipb.ExistingVolume) {
ev.AccessMode = ateapipb.VolumeAccessMode_VOLUME_ACCESS_MODE_READ_ONLY_MANY
}))),
nil,
}, {
"existing volume single-node",
validReq(validActor(withExistingVolume(func(ev *ateapipb.ExistingVolume) {
ev.AccessMode = ateapipb.VolumeAccessMode_VOLUME_ACCESS_MODE_READ_WRITE_ONCE
}))),
field.ErrorList{field.Invalid(field.NewPath("actor", "existing_volumes").Index(0).Child("access_mode"), nil, "").WithOrigin("minimum")},
}, {
"existing volume without an access mode",
validReq(validActor(withExistingVolume(func(ev *ateapipb.ExistingVolume) {
ev.AccessMode = ateapipb.VolumeAccessMode_VOLUME_ACCESS_MODE_UNSPECIFIED
}))),
field.ErrorList{field.Required(field.NewPath("actor", "existing_volumes").Index(0).Child("access_mode"), "")},
}, {
"existing volume escaping its root",
validReq(validActor(withExistingVolume(func(ev *ateapipb.ExistingVolume) { ev.SubPath = "../other" }))),
field.ErrorList{field.Invalid(field.NewPath("actor", "existing_volumes").Index(0).Child("sub_path"), "../other", "")},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -1004,6 +1046,24 @@ func withActorSourceTag(atespace, name string) func(*ateapipb.Actor) {
return func(a *ateapipb.Actor) { a.SourceTag = &ateapipb.ObjectRef{Atespace: atespace, Name: name} }
}

// withExistingVolume returns a modifier func (see validActor) which gives the
// actor one valid existing volume, changed by mod when it is not nil.
func withExistingVolume(mod func(*ateapipb.ExistingVolume)) func(*ateapipb.Actor) {
return func(a *ateapipb.Actor) {
ev := &ateapipb.ExistingVolume{
Name: "workspace",
Driver: "nfs.csi.k8s.io",
VolumeHandle: "nfs-server#share#workspace-1",
AccessMode: ateapipb.VolumeAccessMode_VOLUME_ACCESS_MODE_READ_WRITE_MANY,
SubPath: "sessions/a",
}
if mod != nil {
mod(ev)
}
a.ExistingVolumes = []*ateapipb.ExistingVolume{ev}
}
}

// withActorWorkerAssignment returns a modifier func (see validActor) which sets
// the actor's worker_assignment to a valid value.
func withActorWorkerAssignment(mods ...func(*ateapipb.WorkerAssignment)) func(*ateapipb.ActorStatus) {
Expand Down
Loading
Loading