feat(submit): local + S3 backends for modpack upload contexts, installer-selectable
This commit is contained in:
22 files changed
+2177
-34
No files matched your search
@@ -76,6 +76,32 @@ const (
|
||||
registryVolume = "data"
|
||||
worldsVolume = "worlds"
|
||||
backupVolume = "backup"
|
||||
uploadsVolume = "uploads"
|
||||
|
||||
// uploads* wire the user-uploads build-context store into felis-api. The PVC
|
||||
// backs a LOCAL user_uploads_context — durable across pod restarts and
|
||||
// fsGroup-writable by the non-root pod (unlike a root-owned hostPath). It is
|
||||
// always rendered but only written to when uploads are local. The S3 secret/env
|
||||
// carry credentials for an s3:// user_uploads_context and are OPTIONAL, so a
|
||||
// local-storage install (no such Secret) still starts.
|
||||
uploadsPVCName = "felis-uploads"
|
||||
uploadsStorageSize = "5Gi"
|
||||
// UploadsLocalPath is the in-pod mount of the uploads PVC; a local
|
||||
// user_uploads_context points here so the derived context ref and the on-disk
|
||||
// write location agree. Exported so the setup wizard stamps it into felis.toml.
|
||||
UploadsLocalPath = "/var/lib/felis/uploads"
|
||||
// UploadsS3SecretName is the out-of-band Secret carrying the S3 credentials for
|
||||
// an s3:// user_uploads_context. felis-api mounts its keys into env (optionally)
|
||||
// and the setup wizard creates it. Like configSecretName it is NEVER rendered
|
||||
// into the bundle — the credentials are the same red line.
|
||||
UploadsS3SecretName = "felis-uploads-s3"
|
||||
UploadsS3SecretAccessKey = "access_key_id"
|
||||
UploadsS3SecretSecretKey = "secret_access_key"
|
||||
// UploadsS3AccessKeyEnv / UploadsS3SecretKeyEnv are the env vars felis-api reads
|
||||
// the S3 credentials from; registry.s3.access_key_ref / secret_key_ref default to
|
||||
// these names. The deployment injects them from UploadsS3SecretName (optional).
|
||||
UploadsS3AccessKeyEnv = "FELIS_UPLOADS_S3_ACCESS_KEY"
|
||||
UploadsS3SecretKeyEnv = "FELIS_UPLOADS_S3_SECRET_KEY"
|
||||
|
||||
// worldsMountPath is where the reaper CronJob mounts the worlds-root (read-only).
|
||||
// It is the default of `felis reaper --worlds-root`; the resolver then reads each
|
||||
@@ -129,6 +155,7 @@ func Workloads(p Params) []Object {
|
||||
registryDeployment(p),
|
||||
registryService(p),
|
||||
registryPVC(p),
|
||||
uploadsPVC(p),
|
||||
}
|
||||
if reaperEnabled(p) {
|
||||
objs = append(objs, reaperCronJob(p))
|
||||
@@ -159,6 +186,11 @@ func reaperEnabled(p Params) bool {
|
||||
// restore executor launches `felis restore` with the same image. FELIS_BACKUP_PVC
|
||||
// is rendered only when a backup PVC is named — otherwise the restore endpoint
|
||||
// degrades to 503 rather than enqueuing a Job that cannot mount its backup.
|
||||
//
|
||||
// The uploads PVC is mounted read-write at UploadsLocalPath for a local
|
||||
// user_uploads_context, and the two optional S3 credential env vars
|
||||
// (UploadsS3*Env, from the felis-uploads-s3 Secret) feed an s3:// one — the two
|
||||
// storage backends the setup wizard chooses between.
|
||||
func APIDeployment(p Params) *appsv1.Deployment {
|
||||
p = p.withDefaults()
|
||||
|
||||
@@ -177,6 +209,20 @@ func APIDeployment(p Params) *appsv1.Deployment {
|
||||
if p.BackupPVC != "" {
|
||||
env = append(env, corev1.EnvVar{Name: "FELIS_BACKUP_PVC", Value: p.BackupPVC})
|
||||
}
|
||||
// S3 credentials for an s3:// user_uploads_context, sourced from the
|
||||
// felis-uploads-s3 Secret. Optional: a local-storage install has no such Secret,
|
||||
// and marking these optional lets the pod start anyway (the store falls back to
|
||||
// the uploads PVC). The setup wizard creates the Secret and rolls the API when
|
||||
// the operator picks S3 storage.
|
||||
optional := boolPtr(true)
|
||||
env = append(env,
|
||||
corev1.EnvVar{Name: UploadsS3AccessKeyEnv, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: UploadsS3SecretName}, Key: UploadsS3SecretAccessKey, Optional: optional,
|
||||
}}},
|
||||
corev1.EnvVar{Name: UploadsS3SecretKeyEnv, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: UploadsS3SecretName}, Key: UploadsS3SecretSecretKey, Optional: optional,
|
||||
}}},
|
||||
)
|
||||
|
||||
container := corev1.Container{
|
||||
Name: ComponentAPI,
|
||||
@@ -199,6 +245,10 @@ func APIDeployment(p Params) *appsv1.Deployment {
|
||||
{Name: configVolume, MountPath: configMountPath, ReadOnly: true},
|
||||
{Name: "tls", MountPath: apiTLSMountPath, ReadOnly: true},
|
||||
{Name: tmpVolume, MountPath: "/tmp"},
|
||||
// Read-WRITE: local uploads land here (an s3:// store bypasses it). The
|
||||
// hardened container root is read-only, so this PVC mount is where a local
|
||||
// LocalContextStore can persist a submitted context.
|
||||
{Name: uploadsVolume, MountPath: UploadsLocalPath},
|
||||
},
|
||||
Resources: controlPlaneResources(),
|
||||
SecurityContext: hardenedContainerSecurityContext(),
|
||||
@@ -218,6 +268,12 @@ func APIDeployment(p Params) *appsv1.Deployment {
|
||||
},
|
||||
},
|
||||
{Name: tmpVolume, VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}},
|
||||
{
|
||||
Name: uploadsVolume,
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: uploadsPVCName},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return controlPlaneDeployment(p, SAAPI, container, volumes)
|
||||
@@ -509,6 +565,27 @@ func registryPVC(p Params) *corev1.PersistentVolumeClaim {
|
||||
}
|
||||
}
|
||||
|
||||
// uploadsPVC renders the felis-api user-uploads PVC (spec §16 build-context input
|
||||
// domain). It backs a LOCAL user_uploads_context: felis-api mounts it read-write
|
||||
// at UploadsLocalPath and LocalContextStore writes each submission's context there.
|
||||
// It is always rendered (an s3:// install simply never writes to it) and carries
|
||||
// control-plane labels so it reads as part of felis-api's storage. ReadWriteOnce is
|
||||
// the fail-safe access mode: the single-node starter binds it to felis-api's node,
|
||||
// and a future Kaniko-read integration mounts the same PVC on that node.
|
||||
func uploadsPVC(p Params) *corev1.PersistentVolumeClaim {
|
||||
p = p.withDefaults()
|
||||
return &corev1.PersistentVolumeClaim{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "PersistentVolumeClaim"},
|
||||
ObjectMeta: metav1.ObjectMeta{Name: uploadsPVCName, Namespace: p.ControlNamespace, Labels: controlPlanePodLabels(ComponentAPI)},
|
||||
Spec: corev1.PersistentVolumeClaimSpec{
|
||||
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
|
||||
Resources: corev1.VolumeResourceRequirements{
|
||||
Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse(uploadsStorageSize)},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// registryLabels are the registry's recommended labels. Note the absence of
|
||||
// part-of=felis-control-plane: that is what keeps the registry out of the RCON
|
||||
// NetworkPolicy peer's reach (asserted in workloads_test.go).
|
||||
|
||||
@@ -215,6 +215,44 @@ func TestAPIDeployment_Wiring(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIDeployment_UploadsStorage pins both storage backends' wiring: the local
|
||||
// uploads PVC mounted read-write, and the two S3 credential env vars sourced
|
||||
// optionally from the felis-uploads-s3 Secret (so a local install still starts).
|
||||
func TestAPIDeployment_UploadsStorage(t *testing.T) {
|
||||
ps, c := podSpec(t, APIDeployment(testParams()))
|
||||
|
||||
// Local backend: uploads PVC mounted read-WRITE at UploadsLocalPath.
|
||||
vol := volumeByName(ps.Volumes, uploadsVolume)
|
||||
if vol == nil || vol.PersistentVolumeClaim == nil || vol.PersistentVolumeClaim.ClaimName != uploadsPVCName {
|
||||
t.Fatalf("uploads volume must mount PVC %q, got %#v", uploadsPVCName, vol)
|
||||
}
|
||||
if m := mountByName(c.VolumeMounts, uploadsVolume); m == nil || m.MountPath != UploadsLocalPath || m.ReadOnly {
|
||||
t.Errorf("uploads mount = %#v, want read-write at %s", m, UploadsLocalPath)
|
||||
}
|
||||
|
||||
// S3 backend: both credential env vars come from the Secret (never literals) and
|
||||
// are OPTIONAL, so a local install with no such Secret still starts.
|
||||
for _, ev := range []struct{ name, key string }{
|
||||
{UploadsS3AccessKeyEnv, UploadsS3SecretAccessKey},
|
||||
{UploadsS3SecretKeyEnv, UploadsS3SecretSecretKey},
|
||||
} {
|
||||
e := envVar(c.Env, ev.name)
|
||||
if e == nil || e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil {
|
||||
t.Fatalf("%s must be sourced from a secretKeyRef", ev.name)
|
||||
}
|
||||
ref := e.ValueFrom.SecretKeyRef
|
||||
if ref.Name != UploadsS3SecretName || ref.Key != ev.key {
|
||||
t.Errorf("%s ref = %s/%s, want %s/%s", ev.name, ref.Name, ref.Key, UploadsS3SecretName, ev.key)
|
||||
}
|
||||
if ref.Optional == nil || !*ref.Optional {
|
||||
t.Errorf("%s secretKeyRef must be optional (a local install has no such Secret)", ev.name)
|
||||
}
|
||||
if e.Value != "" {
|
||||
t.Errorf("%s must not carry a literal value", ev.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIService_NodePort(t *testing.T) {
|
||||
p := testParams()
|
||||
p.PanelNodePort = 30445
|
||||
@@ -341,8 +379,8 @@ func TestRegistry_DeploymentServicePVC(t *testing.T) {
|
||||
// every one with TypeMeta (so its YAML header renders).
|
||||
func TestWorkloads_BundleContents(t *testing.T) {
|
||||
objs := Workloads(testParams())
|
||||
if len(objs) != 6 {
|
||||
t.Fatalf("Workloads returned %d objects, want 6", len(objs))
|
||||
if len(objs) != 7 {
|
||||
t.Fatalf("Workloads returned %d objects, want 7", len(objs))
|
||||
}
|
||||
for _, o := range objs {
|
||||
gvk := o.GetObjectKind().GroupVersionKind()
|
||||
|
||||
Reference in new issue
Block a user