Skip to content
Closed
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
26 changes: 26 additions & 0 deletions api/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ func ValidateLabels(labels map[string]string) error {
return nil
}

// ParseLabels parses a comma-separated "key=value" list (the wire/CLI form of
// a label set) into a label map; an empty string means no claims. Parsing is
// syntax only — run the result through ValidateLabels.
func ParseLabels(s string) (map[string]string, error) {
if s == "" {
return nil, nil
}
Comment on lines +93 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Trimming leading and trailing whitespace from the input string s at the beginning of ParseLabels ensures that whitespace-only strings (e.g., " ") are consistently treated as empty and return nil, nil, rather than returning an empty map.

Suggested change
func ParseLabels(s string) (map[string]string, error) {
if s == "" {
return nil, nil
}
func ParseLabels(s string) (map[string]string, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, nil
}

labels := make(map[string]string)
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
k, v, ok := strings.Cut(part, "=")
if !ok {
return nil, fmt.Errorf("invalid label %q: expected key=value", part)
}
key := strings.TrimSpace(k)
if _, exists := labels[key]; exists {
return nil, fmt.Errorf("duplicate label key %q", key)
}
labels[key] = strings.TrimSpace(v)
}
return labels, nil
}

// A node declares its own labels when it enrols, so on its own a label is a
// claim rather than an attestation. A role's allowed_labels is what makes it
// one: the control plane only signs a label the operator said that role may
Expand Down
19 changes: 19 additions & 0 deletions api/labels_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,22 @@ func TestValidateLabels_AllKeysValidStaysSorted(t *testing.T) {
t.Errorf("ValidateLabels(sorted happy path): unexpected error: %v", err)
}
}

func TestParseLabels(t *testing.T) {
labels, err := ParseLabels("region=eu-west-1, team = platform")
if err != nil {
t.Fatalf("ParseLabels failed: %v", err)
}
if labels["region"] != "eu-west-1" || labels["team"] != "platform" {
t.Fatalf("unexpected labels: %v", labels)
}
if labels, err := ParseLabels(""); err != nil || labels != nil {
t.Fatalf("empty string should yield no labels, got %v, %v", labels, err)
}
Comment on lines +119 to +121

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a test case to verify that whitespace-only strings are correctly parsed as empty/nil labels, matching the updated ParseLabels behavior.

Suggested change
if labels, err := ParseLabels(""); err != nil || labels != nil {
t.Fatalf("empty string should yield no labels, got %v, %v", labels, err)
}
if labels, err := ParseLabels(""); err != nil || labels != nil {
t.Fatalf("empty string should yield no labels, got %v, %v", labels, err)
}
if labels, err := ParseLabels(" "); err != nil || labels != nil {
t.Fatalf("whitespace-only string should yield no labels, got %v, %v", labels, err)
}

if _, err := ParseLabels("no-equals"); err == nil {
t.Fatal("expected error for label without =")
}
if _, err := ParseLabels("k=a,k=b"); err == nil {
t.Fatal("expected error for duplicate key")
}
}
29 changes: 2 additions & 27 deletions cmd/sam-node/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,31 +259,6 @@ func interactiveJoin(ctx context.Context, store *node.Store, targetControlPlane
return jwtStr, info, nil
}

// parseLabelsFlag parses a comma-separated "key=value" list (see
// api/labels.go) into a label map; an empty string means no claims.
func parseLabelsFlag(s string) (map[string]string, error) {
if s == "" {
return nil, nil
}
labels := make(map[string]string)
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
k, v, ok := strings.Cut(part, "=")
if !ok {
return nil, fmt.Errorf("invalid label %q: expected key=value", part)
}
key := strings.TrimSpace(k)
if _, exists := labels[key]; exists {
return nil, fmt.Errorf("duplicate label key %q", key)
}
labels[key] = strings.TrimSpace(v)
}
return labels, nil
}

func main() {
rootCmd := &cobra.Command{
Use: "sam-node",
Expand Down Expand Up @@ -327,7 +302,7 @@ func main() {
if jwtFlag != "" {
logger.Warn("--jwt passes a secret on the command line; prefer --jwt-path")
}
labels, err := parseLabelsFlag(labelsFlag)
labels, err := api.ParseLabels(labelsFlag)
if err != nil {
logger.Fatalf("Invalid --labels: %v", err)
}
Expand Down Expand Up @@ -746,7 +721,7 @@ func main() {
}
}

labels, err := parseLabelsFlag(labelsFlag)
labels, err := api.ParseLabels(labelsFlag)
if err != nil {
logger.Fatalf("Invalid --labels: %v", err)
}
Expand Down
19 changes: 0 additions & 19 deletions cmd/sam-node/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,25 +62,6 @@ func TestResolveSocketPath(t *testing.T) {
})
}

func TestParseLabelsFlag(t *testing.T) {
if got, err := parseLabelsFlag(""); got != nil || err != nil {
t.Errorf("empty flag: got %v, %v; want nil, nil", got, err)
}

got, err := parseLabelsFlag(" region=eu , team=platform ,,")
if err != nil || len(got) != 2 || got["region"] != "eu" || got["team"] != "platform" {
t.Errorf("parse should split key=value pairs: got %v, %v", got, err)
}

if _, err := parseLabelsFlag("noequals"); err == nil {
t.Error("entry without '=' must be rejected")
}

if _, err := parseLabelsFlag("region=us-east-1,region=us-west-1"); err == nil {
t.Error("duplicate label key must be rejected")
}
}

func TestNormalizeControlPlaneURL(t *testing.T) {
cases := map[string]string{
"bananas.sam-mesh.dev": "https://bananas.sam-mesh.dev",
Expand Down
7 changes: 7 additions & 0 deletions development/kind/dex.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ data:
- id: sam-console
redirectURIs:
- ${CONSOLE_REDIRECT_URI}
# The sam-node CLI and the mobile app log in through Dex's device flow or a
# loopback browser callback; Dex rejects both unless listed here.
- /device/callback
# Fixed ports from internal/node/oidc.go and mobile/sam-node-app/lib/main.dart.
- http://127.0.0.1:13000/callback
- http://127.0.0.1:13001/callback
- http://127.0.0.1:13002/callback
name: 'SAM Console'
public: true
enablePasswordDB: true
Expand Down
11 changes: 11 additions & 0 deletions development/kind/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ deploy_chart() {
--set controlPlane.allowedAudiences="${ALLOWED_AUDIENCES//,/\\,}" \
--set controlPlane.insecureSkipTlsVerify=true \
--set 'bootstrap.nodeServices={*}' \
--set 'bootstrap.nodeLabels={*}' \
--set 'bootstrap.nodeMembers={sam:system:authenticated}' \
--set gateway.enabled=true \
--set gateway.className=cloud-provider-kind \
Expand Down Expand Up @@ -131,10 +132,20 @@ tmuxs() { tmux -L samsocket -f /dev/null "$@"; }
show_cluster_logs() {
tmuxs kill-session -t "${SESSION}" 2>/dev/null || true

# Looked up here, not inherited, so `-l` gets the header too; one direct query rather
# than gateway_ip's polling, so the logs still open when a gateway has no address.
local main_ip dex_ip
main_ip="$(kubectl --context "${KCTX}" -n "${NAMESPACE}" get gateway sam-mesh-gateway -o jsonpath='{.status.addresses[0].value}' 2>/dev/null || true)"
dex_ip="$(kubectl --context "${KCTX}" -n "${NAMESPACE}" get gateway sam-mesh-dex-gateway -o jsonpath='{.status.addresses[0].value}' 2>/dev/null || true)"

tmuxs new-session -d -s "${SESSION}" -n mesh "$(logs control-plane 'deploy/sam-mesh-control-plane')" \; set -t "${SESSION}" destroy-unattached off
tmuxs split-window -t "${SESSION}:0" "$(logs router 'statefulset/sam-mesh-router')"
tmuxs set-option -t "${SESSION}" -g pane-border-status top
tmuxs set-option -t "${SESSION}" -g pane-border-format ' #{pane_title} '
tmuxs set-option -t "${SESSION}" status-position top
tmuxs set-option -t "${SESSION}" status-left-length 250
tmuxs set-option -t "${SESSION}" status-right ''
tmuxs set-option -t "${SESSION}" status-left " console http://${main_ip:-?}${CONSOLE_BASE_PATH}/ control plane http://${main_ip:-?} dex http://${dex_ip:-?}/dex "

# Title the tmux panes in creation order: control-plane, router.
titles=(control-plane router)
Expand Down
12 changes: 7 additions & 5 deletions mobile/sam-node-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,21 @@ To build the app, you must first compile the Go FFI library and bundle it inside

### 1. Compile FFI Library

Run one of the following commands from the **repository root directory**:
Run one of the following from the **repository root directory**. The `mobile-ffi-*` targets only build into `bin/`; the `cp` step puts the library where the Flutter project loads it from. (`make mobile-app-apk` and `make mobile-app-apk-emulator` do build, copy and release APK in one go.)

* **For Android ARM64 Devices (Physical Phones)**:
* **For Android ARM64 (physical phones, and emulators on Apple Silicon hosts)**:
```bash
make mobile-ffi-android
mkdir -p mobile/sam-node-app/android/app/src/main/jniLibs/arm64-v8a
cp bin/android/libsam.so mobile/sam-node-app/android/app/src/main/jniLibs/arm64-v8a/
```
*Copies the binary to `mobile/sam-node-app/android/app/src/main/jniLibs/arm64-v8a/libsam.so`*

* **For Android x86_64 Emulators (AVD)**:
* **For Android x86_64 emulators (Intel and Linux hosts)**:
```bash
make mobile-ffi-android-x86_64
mkdir -p mobile/sam-node-app/android/app/src/main/jniLibs/x86_64
cp bin/android-x86_64/libsam.so mobile/sam-node-app/android/app/src/main/jniLibs/x86_64/
```
*Copies the binary to `mobile/sam-node-app/android/app/src/main/jniLibs/x86_64/libsam.so`*

* **For iOS Devices**:
```bash
Expand Down
4 changes: 4 additions & 0 deletions mobile/sam-node-app/analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
analyzer:
exclude:
- build/**
- android/**
include: package:flutter_lints/flutter.yaml

linter:
Expand Down
3 changes: 3 additions & 0 deletions mobile/sam-node-app/android/app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Debug builds talk plain http to a local control plane and Dex; Flutter blocks
non-loopback http unless the platform policy allows it. -->
<application android:networkSecurityConfig="@xml/network_security_config"/>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
2 changes: 1 addition & 1 deletion mobile/sam-node-app/integration_test/e2e_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ void main() {

// 3. Enroll Node against the host control plane
const controlPlaneURL = 'http://127.0.0.1:37001';
final enrollErr = samLib.enroll(dataDir, controlPlaneURL, jwt, true);
final enrollErr = samLib.enroll(dataDir, controlPlaneURL, jwt, true, '');
expect(enrollErr, isNull);

// Start local Mock MCP Server inside the Android emulator. It must be
Expand Down
Loading
Loading