|
7 | 7 | "context" |
8 | 8 | "encoding/json" |
9 | 9 | "errors" |
| 10 | + "fmt" |
10 | 11 | "reflect" |
11 | 12 | "sort" |
12 | 13 | "strings" |
@@ -1195,74 +1196,120 @@ func TestCreateAcceptsEmptyAttachments(t *testing.T) { |
1195 | 1196 | } |
1196 | 1197 | } |
1197 | 1198 |
|
| 1199 | +// specWire returns the backing policy's spec as Cilium decodes it: the CRD and |
| 1200 | +// the agent read the wire form, not the Go struct. |
| 1201 | +func specWire(t *testing.T, np *CiliumNetworkPolicy) map[string]any { |
| 1202 | + t.Helper() |
| 1203 | + raw, err := json.Marshal(np) |
| 1204 | + if err != nil { |
| 1205 | + t.Fatalf("marshal backing policy: %v", err) |
| 1206 | + } |
| 1207 | + var decoded struct { |
| 1208 | + Spec map[string]any `json:"spec"` |
| 1209 | + } |
| 1210 | + if err := json.Unmarshal(raw, &decoded); err != nil { |
| 1211 | + t.Fatalf("unmarshal backing policy: %v", err) |
| 1212 | + } |
| 1213 | + return decoded.Spec |
| 1214 | +} |
| 1215 | + |
| 1216 | +// ciliumAcceptsRuleSections mirrors the first check of Cilium's |
| 1217 | +// api.Rule.Sanitize, which marks a policy Valid=False when none of its rule |
| 1218 | +// sections has an entry. The CRD anyOf only needs one of the keys to be |
| 1219 | +// present, so it admits an empty list that the agent then rejects. |
| 1220 | +func ciliumAcceptsRuleSections(spec map[string]any) bool { |
| 1221 | + for _, k := range []string{"ingress", "ingressDeny", "egress", "egressDeny"} { |
| 1222 | + if l, _ := spec[k].([]any); len(l) > 0 { |
| 1223 | + return true |
| 1224 | + } |
| 1225 | + } |
| 1226 | + return false |
| 1227 | +} |
| 1228 | + |
| 1229 | +func getBackingPolicy(t *testing.T, r *REST, name string) *CiliumNetworkPolicy { |
| 1230 | + t.Helper() |
| 1231 | + np := &CiliumNetworkPolicy{} |
| 1232 | + if err := r.c.Get(context.Background(), types.NamespacedName{Namespace: testNamespace, Name: name}, np); err != nil { |
| 1233 | + t.Fatalf("backing policy not found: %v", err) |
| 1234 | + } |
| 1235 | + return np |
| 1236 | +} |
| 1237 | + |
1198 | 1238 | func TestRulesLessSecurityGroupProjectsValidPolicy(t *testing.T) { |
1199 | 1239 | r := newTestREST(t) |
1200 | 1240 | // A membership-only SecurityGroup attaches to an application to stamp the |
1201 | | - // membership label but carries no ingress or egress rules. The backing |
1202 | | - // CiliumNetworkPolicy CRD still requires at least one rule section to be |
1203 | | - // present (spec anyOf: ingress|ingressDeny|egress|egressDeny), so a |
1204 | | - // selector-only spec is rejected synchronously by the cilium.io/v2 API. The |
1205 | | - // projection must emit an explicit empty ingress list — the CRD's documented |
1206 | | - // no-op ("if omitted or empty, this rule does not apply at ingress") — so the |
1207 | | - // policy is schema-valid yet leaves the member pods' connectivity untouched. |
| 1241 | + // membership label but carries no ingress or egress rules. Its backing |
| 1242 | + // policy must be one Cilium accepts and that changes nothing in the |
| 1243 | + // datapath: a rule section with an entry that allows nothing, and default |
| 1244 | + // deny disabled in both directions so that entry does not isolate the |
| 1245 | + // member pods. |
1208 | 1246 | createSG(t, r, &sdnv1alpha1.SecurityGroup{ |
1209 | 1247 | ObjectMeta: metav1.ObjectMeta{Name: "sg-membership", Namespace: testNamespace}, |
1210 | 1248 | Spec: sdnv1alpha1.SecurityGroupSpec{ |
1211 | 1249 | Attachments: []sdnv1alpha1.ApplicationReference{{Kind: "Postgres", Name: "db"}}, |
1212 | 1250 | }, |
1213 | 1251 | }) |
1214 | 1252 |
|
1215 | | - np := &CiliumNetworkPolicy{} |
1216 | | - if err := r.c.Get(context.Background(), types.NamespacedName{Namespace: testNamespace, Name: "sg-membership"}, np); err != nil { |
1217 | | - t.Fatalf("backing policy not found: %v", err) |
1218 | | - } |
1219 | | - |
1220 | | - // The CRD validates the wire form, not the Go struct, so assert on the |
1221 | | - // marshaled bytes: spec.ingress must be present (an empty list satisfies the |
1222 | | - // anyOf), and spec.egress must be absent (ingress is the guaranteed-present |
1223 | | - // carrier; egress is emitted only when there are egress rules). |
1224 | | - raw, err := json.Marshal(np) |
1225 | | - if err != nil { |
1226 | | - t.Fatalf("marshal backing policy: %v", err) |
| 1253 | + spec := specWire(t, getBackingPolicy(t, r, "sg-membership")) |
| 1254 | + if !ciliumAcceptsRuleSections(spec) { |
| 1255 | + t.Fatalf("rules-less policy has no rule entry, Cilium marks it Valid=False: %v", spec) |
1227 | 1256 | } |
1228 | | - var decoded struct { |
1229 | | - Spec map[string]json.RawMessage `json:"spec"` |
| 1257 | + if got, want := fmt.Sprint(spec["ingress"]), "[map[]]"; got != want { |
| 1258 | + t.Fatalf("rules-less policy ingress = %s, want %s (one rule that allows nothing)", got, want) |
1230 | 1259 | } |
1231 | | - if err := json.Unmarshal(raw, &decoded); err != nil { |
1232 | | - t.Fatalf("unmarshal backing policy: %v", err) |
| 1260 | + if _, ok := spec["egress"]; ok { |
| 1261 | + t.Fatalf("rules-less policy must not carry an egress key: %v", spec) |
1233 | 1262 | } |
1234 | | - ing, ok := decoded.Spec["ingress"] |
1235 | | - if !ok { |
1236 | | - t.Fatalf("backing policy spec has no ingress key; the CRD anyOf would reject it: %s", raw) |
| 1263 | + if got, want := fmt.Sprint(spec["enableDefaultDeny"]), "map[egress:false ingress:false]"; got != want { |
| 1264 | + t.Fatalf("rules-less policy enableDefaultDeny = %s, want %s", got, want) |
1237 | 1265 | } |
1238 | | - if string(ing) != "[]" { |
1239 | | - t.Fatalf("rules-less policy ingress = %s, want []", ing) |
| 1266 | + |
| 1267 | + // On read, the placeholder rule is hidden, so a rules-less SecurityGroup |
| 1268 | + // round-trips with neither ingress nor egress. |
| 1269 | + out, err := r.Get(ctxNS(), "sg-membership", &metav1.GetOptions{}) |
| 1270 | + if err != nil { |
| 1271 | + t.Fatalf("Get returned error: %v", err) |
1240 | 1272 | } |
1241 | | - if _, ok := decoded.Spec["egress"]; ok { |
1242 | | - t.Fatalf("rules-less policy must not carry an egress key: %s", raw) |
| 1273 | + got := out.(*sdnv1alpha1.SecurityGroup) |
| 1274 | + if got.Spec.Ingress != nil || got.Spec.Egress != nil { |
| 1275 | + t.Fatalf("rules-less round-trip should carry no ingress/egress, got: %+v", got.Spec) |
1243 | 1276 | } |
1244 | 1277 |
|
1245 | | - // The same key must survive the unstructured conversion path (which honors |
1246 | | - // omitempty independently of json.Marshal), so the empty list cannot be |
1247 | | - // silently dropped there either. |
1248 | | - u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(np) |
1249 | | - if err != nil { |
1250 | | - t.Fatalf("to unstructured: %v", err) |
| 1278 | + // Adding a rule must drop the default-deny override, or the group's |
| 1279 | + // rules would allow traffic without isolating the member pods. |
| 1280 | + got.Spec.Egress = []sdnv1alpha1.EgressRule{{ToCIDR: []string{"10.0.0.0/8"}}} |
| 1281 | + if _, _, err := r.Update(ctxNS(), "sg-membership", rest.DefaultUpdatedObjectInfo(got), nil, nil, false, &metav1.UpdateOptions{}); err != nil { |
| 1282 | + t.Fatalf("Update returned error: %v", err) |
1251 | 1283 | } |
1252 | | - spec, _ := u["spec"].(map[string]any) |
1253 | | - if _, ok := spec["ingress"]; !ok { |
1254 | | - t.Fatalf("unstructured backing policy spec has no ingress key: %v", spec) |
| 1284 | + spec = specWire(t, getBackingPolicy(t, r, "sg-membership")) |
| 1285 | + if _, ok := spec["enableDefaultDeny"]; ok { |
| 1286 | + t.Fatalf("policy with rules must not override default deny: %v", spec) |
1255 | 1287 | } |
| 1288 | + if _, ok := spec["ingress"]; ok { |
| 1289 | + t.Fatalf("egress-only policy must not carry an ingress key: %v", spec) |
| 1290 | + } |
| 1291 | +} |
1256 | 1292 |
|
1257 | | - // On read, the synthetic empty ingress list collapses back to no rules, so a |
1258 | | - // rules-less SecurityGroup round-trips with neither ingress nor egress. |
1259 | | - out, err := r.Get(ctxNS(), "sg-membership", &metav1.GetOptions{}) |
| 1293 | +func TestEmptyIngressRuleIsNotMembershipOnly(t *testing.T) { |
| 1294 | + r := newTestREST(t) |
| 1295 | + // An explicit empty ingress rule projects to the same rule as the |
| 1296 | + // membership-only placeholder, but it is tenant-authored: it keeps Cilium's |
| 1297 | + // ingress default deny and must stay visible on read. |
| 1298 | + createSG(t, r, &sdnv1alpha1.SecurityGroup{ |
| 1299 | + ObjectMeta: metav1.ObjectMeta{Name: "sg-deny-in", Namespace: testNamespace}, |
| 1300 | + Spec: sdnv1alpha1.SecurityGroupSpec{Ingress: []sdnv1alpha1.IngressRule{{}}}, |
| 1301 | + }) |
| 1302 | + |
| 1303 | + spec := specWire(t, getBackingPolicy(t, r, "sg-deny-in")) |
| 1304 | + if _, ok := spec["enableDefaultDeny"]; ok { |
| 1305 | + t.Fatalf("an empty ingress rule must keep default deny: %v", spec) |
| 1306 | + } |
| 1307 | + out, err := r.Get(ctxNS(), "sg-deny-in", &metav1.GetOptions{}) |
1260 | 1308 | if err != nil { |
1261 | 1309 | t.Fatalf("Get returned error: %v", err) |
1262 | 1310 | } |
1263 | | - got := out.(*sdnv1alpha1.SecurityGroup) |
1264 | | - if got.Spec.Ingress != nil || got.Spec.Egress != nil { |
1265 | | - t.Fatalf("rules-less round-trip should carry no ingress/egress, got: %+v", got.Spec) |
| 1311 | + if got := out.(*sdnv1alpha1.SecurityGroup).Spec.Ingress; len(got) != 1 { |
| 1312 | + t.Fatalf("empty ingress rule should round-trip as one rule, got: %+v", got) |
1266 | 1313 | } |
1267 | 1314 | } |
1268 | 1315 |
|
|
0 commit comments