Posts for: #Ldap

openldap スキーマの validator と go のマルチエラー制御

今日もメンバーのコードレビューに半日以上の時間を割いたので自分の時間をあまり取れなくて開発が進捗しなかった。

隙間時間で openldap スキーマの情報 を使って validator を実装していた。バリデーションのようなものはまとめてエラーを返せるのが望ましい。go でマルチエラーを制御する仕組みが少し前に追加されていたことを思い出した。Go 1.20 Wrapping multiple errors のチュートリアル記事を読みながら openldap スキーマの validator を実装して複数のエラーを返すようにした。まだ抜け・漏れはあるかもしれないけど objectClass の validator は次のようになる。

type ObjectClassValidator struct {
	objectClassMap map[string]ObjectClassDescription
}

func (v *ObjectClassValidator) Validate(attr map[string][]string) error {
	var errs []error
	var objClassDescs []ObjectClassDescription
	hasStructuralKind := false
	allAttrMap := map[string]struct{}{}
	lowerAttrNameMap := make(map[string]struct{}, len(attr))
	for k, values := range attr {
		lowerName := strings.ToLower(ParseAttribute(k))
		lowerAttrNameMap[lowerName] = struct{}{}
		if lowerName == "objectclass" {
			objClassDescs = make([]ObjectClassDescription, 0, len(values))
			for _, objcName := range values {
				lowerObjcName := strings.ToLower(objcName)
				ocd, ok := v.objectClassMap[lowerObjcName]
				if !ok {
					msg := fmt.Sprintf("'%s' objectClass is not defined", objcName)
					errs = append(errs, NewErrorValidation(msg))
					continue
				}
				if ocd.Kind != nil && *ocd.Kind == Structural {
					hasStructuralKind = true
				}
				objClassDescs = append(objClassDescs, ocd)
				for mustAttr := range ocd.GetMust() {
					allAttrMap[strings.ToLower(mustAttr)] = struct{}{}
				}
				for mayAttr := range ocd.GetMay() {
					allAttrMap[strings.ToLower(mayAttr)] = struct{}{}
				}
			}
		}
	}

	// require at least 1 structural objectClass
	if !hasStructuralKind {
		errs = append(errs, NewErrorValidation("no structural objectClass"))
	}

	// must in objectClass requires must attributes
	for _, ocd := range objClassDescs {
		for mustAttr := range ocd.GetMust() {
			if _, ok := lowerAttrNameMap[strings.ToLower(mustAttr)]; !ok {
				msg := fmt.Sprintf("'%s' objectClass requires '%s'", ocd.GetName(), mustAttr)
				errs = append(errs, NewErrorValidation(msg))
				continue
			}
		}
	}

	// all attribute keys are allowed may/must in objectClass
	for attrName := range lowerAttrNameMap {
		if _, ok := allAttrMap[attrName]; !ok {
			msg := fmt.Sprintf("'%s' is not allowed by objectClass", attrName)
			errs = append(errs, NewErrorValidation(msg))
			continue
		}
	}
	return errors.Join(errs...)
}

func NewObjectClassValidator(
	objectClasses []ObjectClassDescription,
) *ObjectClassValidator {
	ocm := make(map[string]ObjectClassDescription, len(objectClasses))
	for _, v := range objectClasses {
		for _, n := range v.Name {
			ocm[strings.ToLower(n)] = v
		}
	}
	return &ObjectClassValidator{
		objectClassMap: ocm,
	}
}

次のように複数のエラーを返すテストケースを定義する。

{
	name:       "multiple errors",
	objClasses: objectClasses1,
	attr: map[string][]string{
		"objectClass":           []string{"top", "posixAccount", "myObjeClass"},
		"uid":                   nil,
		"mail":                  nil,
		"mail;lang-ja;phonetic": nil,
		"employeeType;lang-ja":  nil,
		"homeDirectory":         nil,
		"GIDNumber":             nil,
		"street":                nil,
	},
	expected: []error{
		openldap.NewErrorValidation("'employeetype' is not allowed by objectClass"),
		openldap.NewErrorValidation("'mail' is not allowed by objectClass"),
		openldap.NewErrorValidation("'myObjeClass' objectClass is not defined"),
		openldap.NewErrorValidation("'posixAccount' objectClass requires 'cn'"),
		openldap.NewErrorValidation("'posixAccount' objectClass requires 'uidNumber'"),
		openldap.NewErrorValidation("'street' is not allowed by objectClass"),
		openldap.NewErrorValidation("no structural objectClass"),
	},
},

テストコードで次のように返ってくる error を unwrap することで検証できる。

for _, tt := range tests {
	t.Run(tt.name, func(t *testing.T) {
		t.Parallel()
		validator := openldap.NewObjectClassValidator(tt.objClasses)
		err := validator.Validate(tt.attr)
		if actual, ok := err.(interface{ Unwrap() []error }); ok {
			t.Log("\n" + err.Error())
			if diff := cmp.Diff(tt.expected, actual.Unwrap(), opts...); diff != "" {
				t.Error(diff)
				return
			}
		}
	})
}

ldap スキーマの情報を返す web api の実装

一昨日昨日 と ldap スキーマの parser について調査して実装した。最終的には構文解析した値をそのまますべて返すわけではなく、実際に ui や内部の処理で必要な情報のみを使いやすい構造にして web api として実装する。マージリクエストを作成してメンバーにコードレビューをしてもらっていて、スキーマ情報だけでは足りない仕様に1つ気付いた。たとえば、次の cn という属性の定義には SUP (super の略語で親の定義から派生していることを表す) として name が定義されている。

attributetype ( 2.5.4.41 NAME 'name'
       EQUALITY caseIgnoreMatch
       SUBSTR caseIgnoreSubstringsMatch
       SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )

attributetype ( 2.5.4.3 NAME ( 'cn' 'commonName' )
       DESC 'RFC2256: common name(s) for which the entity is known by'
       SUP name )

cn の定義には SYNTAX は定義されていないが、これは name の定義にある SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 (Directory String のこと) の oid を継承するという仕様になっている。自分で SUP を調べて継承元の情報を取得するのは面倒なので構文解析した後に SUP が指定されていれば Equality, Ordering, SubStr, Syntax を親から取得するように改善した。こういうところは自前で parser を実装しているので自分たちの都合にあわせてカスタマイズしやすい。

3日もかかってしまったが、これで ldap スキーマを返す web api の実装を完了できた。いまの開発フェーズにおける、私が機能拡張する issue はこれで完了にしようと考えている。来週からはテストや qa 向けの作業にのみ注力していこうと思う。本番導入前に少しでも運用トラブルの懸念を減らす、もしくは品質をあげるための取り組みをあと3週間やってからしっかり検証をしていく。ここ1ヶ月ほど深夜までコードを書いていることが多かった。終わってみれば、面倒で厄介な issue も大半を fix した。当たり前の話だけど、四六時中ずっとコードを書いていたら気付いたら成果として積み上がってきた。マネージャーを移管したことでその分の労力を開発にまわせているのもある。

go で parser を実装してみた

昨日の ldap スキーマ parser 実装 の続き。parser generator は使わず自分で parser を実装してみることにした。go の標準ライブラリにある text/scanner を使って字句解析をしたら、次のカスタマイズを行うことでそれっぽく token に分解できた。

s.Mode ^= scanner.ScanFloats
s.IsIdentRune = func(ch rune, i int) bool {
	return ch == '.' ||
		unicode.IsLetter(ch) ||
		unicode.IsDigit(ch)
}

しかし、text/scanner はあくまで go のソースコードを字句解析するためのツールになる。ldap スキーマの字句解析も一応は意図したように分割できたが 'objectClass' のようなシングルクォートで文字列を囲むような構文を go のソースコードでは記述できないため、エラーをチェックすると invalid char literal として必ずエラーになってしまう。これでは字句解析のエラーチェックをできなくなってしまうため text/scanner の利用は断念した。

そこで bufio#Scanner を使って字句解析を実装した。字句解析を行うツールを lexer または tokenizer と呼ぶ。go ではこのツールを scanner と呼ぶ慣習になってるようにみえる。分割する token をカスタマイズするには SplitFunc を定義する。ldap スキーマの字句解析は次のように定義できた。

const (
	singleQuote byte = '\''
)

func tokenize(
	data []byte, atEOF bool,
) (
	advance int, token []byte, err error,
) {
	advance, token, err = bufio.ScanWords(data, atEOF)
	if err != nil {
		return
	}
	if len(token) > 0 && token[0] == singleQuote {
		since := 0
		if data[0] != singleQuote {
			since = bytes.IndexByte(data, singleQuote)
		}
		i := bytes.IndexByte(data[since+1:], singleQuote)
		if i == -1 {
			return 0, data, bufio.ErrFinalToken
		}
		pos := since + i + 2
		return pos, data[since:pos], nil
	}
	return
}

func NewScanner(src string) *bufio.Scanner {
	s := bufio.NewScanner(strings.NewReader(src))
	s.Split(tokenize)
	return s
}

次のような ldap スキーマの文字列は、

( 2.5.4.0 NAME 'objectClass' DESC 'RFC4512: object classes of the entity' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )

次のように token として分割される。

(
2.5.4.0
NAME
'objectClass'
DESC
'RFC4512: object classes of the entity'
EQUALITY
objectIdentifierMatch
SYNTAX
1.3.6.1.4.1.1466.115.121.1.38
)

あとはこれらの token から構文解析を行う parser を実装するだけ。AttributeTypeDescription の文法は次になる。

AttributeTypeDescription = "(" whsp
      numericoid whsp              ; AttributeType identifier
    [ "NAME" qdescrs ]             ; name used in AttributeType
    [ "DESC" qdstring ]            ; description
    [ "OBSOLETE" whsp ]
    [ "SUP" woid ]                 ; derived from this other
                                   ; AttributeType
    [ "EQUALITY" woid              ; Matching Rule name
    [ "ORDERING" woid              ; Matching Rule name
    [ "SUBSTR" woid ]              ; Matching Rule name
    [ "SYNTAX" whsp noidlen whsp ] ; Syntax OID
    [ "SINGLE-VALUE" whsp ]        ; default multi-valued
    [ "COLLECTIVE" whsp ]          ; default not collective
    [ "NO-USER-MODIFICATION" whsp ]; default user modifiable
    [ "USAGE" whsp AttributeUsage ]; default userApplications
    whsp ")"

AttributeUsage =
    "userApplications"     /
    "directoryOperation"   /
    "distributedOperation" / ; DSA-shared
    "dSAOperation"          ; DSA-specific, value depends on server

字句解析する scanner と組み合わせて実装した parser は次のようになる。

func ParseAttributeTypeDescription(
	src string,
) (*AttributeTypeDescription, error) {
	var err error
	d := &AttributeTypeDescription{}
	s := NewScanner(src)
	for s.Scan() {
		token := s.Text()
		switch token {
		case leftParenthesis:
			if s.Scan() {
				d.OID = OID{Value: s.Text()}
			}
		case rightParenthesis:
			// do nothing
		case "NAME":
			if s.Scan() {
				d.Name = ParseNAME(s)
			}
		case "DESC":
			if s.Scan() {
				d.Desc = Unquote(s.Text())
			}
		case "OBSOLETE":
			d.Obsolete = true
		case "SUP":
			if s.Scan() {
				d.Sup = NewWOID(s.Text())
			}
		case "EQUALITY":
			if s.Scan() {
				d.Equality = NewWOID(s.Text())
			}
		case "ORDERING":
			if s.Scan() {
				d.Ordering = NewWOID(s.Text())
			}
		case "SUBSTR":
			if s.Scan() {
				d.SubStr = NewWOID(s.Text())
			}
		case "SYNTAX":
			if s.Scan() {
				d.Syntax, err = ParseNOIDLen(s.Text())
				if err != nil {
					return nil, fmt.Errorf("failed to parse SYNTAX: %w", err)
				}
			}
		case "SINGLE-VALUE":
			d.SingleValue = true
		case "COLLECTIVE":
			d.Collective = true
		case "NO-USER-MODIFICATION":
			d.NoUserModification = true
		case "USAGE":
			if s.Scan() {
				usage := GetAttributeTypeUsage(s.Text())
				d.Usage = &usage
			}
		default:
			if strings.HasPrefix(token, "X-") {
				if s.Scan() {
					if d.Extension == nil {
						d.Extension = make(map[string][]string)
					}
					d.Extension[token] = ParseQuotedStrings(s)
				}
				break
			}
			slog.Warn("unsupported", "token", token, "oid", d.OID, "name", d.Name)
		}
	}
	if err := s.Err(); err != nil {
		return nil, fmt.Errorf("failed to scan: %w", err)
	}
	return d, nil
}

昨日の調査で go 製の parser generator をあまりみつけられなかったことに気付いた。その理由を理解できた。go で初めて parser を実装してみて簡単に実装できることに気付いた。標準ライブラリにある text/scanner や bufio#Scanner を使うと簡単に字句解析できるため、自分で parser を実装する労力が小さい。したがって parser generator を使って実装するよりも、自分で parser を実装することを選ぶ開発者が多いのではないかと推測する。

openldap スキーマと parser generator

この開発フェーズではあまり機能拡張を行わない方針としているが、できればやっておいた方がよい機能拡張の1つに openldap サーバーから ldap スキーマを取得する処理がある。ちょうどいま若いメンバーに実装してもらっている大きな機能のバリデーションにも ldap スキーマの情報があると便利そうなので私が対応することに決めた。

openldap サーバーで管理している ldap スキーマの定義は openldap サーバーの Schema Specificationrfc 4512 の2つをみると仕様がわかる。たとえば、次のように AttributeTypeDescription というスキーマの定義は Augmented Backus–Naur form (abnf) という記法で定義されている。rfc などのネットワークプロトコルの世界では abnf のフォーマットで仕様を説明することが多いらしい。

AttributeTypeDescription = "(" whsp
      numericoid whsp              ; AttributeType identifier
    [ "NAME" qdescrs ]             ; name used in AttributeType
    [ "DESC" qdstring ]            ; description
    [ "OBSOLETE" whsp ]
    [ "SUP" woid ]                 ; derived from this other
                                   ; AttributeType
    [ "EQUALITY" woid              ; Matching Rule name
    [ "ORDERING" woid              ; Matching Rule name
    [ "SUBSTR" woid ]              ; Matching Rule name
    [ "SYNTAX" whsp noidlen whsp ] ; Syntax OID
    [ "SINGLE-VALUE" whsp ]        ; default multi-valued
    [ "COLLECTIVE" whsp ]          ; default not collective
    [ "NO-USER-MODIFICATION" whsp ]; default user modifiable
    [ "USAGE" whsp AttributeUsage ]; default userApplications
    whsp ")"

AttributeUsage =
    "userApplications"     /
    "directoryOperation"   /
    "distributedOperation" / ; DSA-shared
    "dSAOperation"          ; DSA-specific, value depends on server

openldap サーバーに対して ldap スキーマを取得する方法は How can I fetch schema information from the server? の faq に書いてある。search base に対してサブスキーマサブエントリを返す dn を取得する。openldap サーバーの場合 ldap の root ツリーに対応するのは cn=Subschema がデフォルトとなる。

$ ldapsearch -H ldap://localhost -x -LLL -b dc=example,dc=com -s base subschemaSubentry
dn: dc=example,dc=com
subschemaSubentry: cn=Subschema

この dn にスキーマ情報の属性が格納されているのでそれらを取得する。このスキーマ情報は operational attributes として管理されているので + という記号が operational attributes 属性群をまとめて取得するキーワードになっている。

$ ldapsearch -x -LLL -b cn=Subschema -s base '(objectClass=subschema)' +

これでスキーマ情報を取得できる。先に書いた AttributeTypeDescription の実際のスキーマ情報は次のような内容になる。こういったスキーマのテキスト情報をたくさん取得できる。

( 2.5.4.0 NAME 'objectClass' DESC 'RFC4512: object classes of the entity' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )

go の parser generator 調査

この手のものは abnf から parser をコード生成するのが一般的なやり方かな?と、まずは go で使えそうな parser generator について調べてみた。意外と go 製の parser generator はみつからなかった。私が発見できたのは次の3つぐらい。

最後の antlr は java 製のツールだけれども、go のソースコードを出力できるので go 実装の parser をコード生成できるという意図で対象としている。goyacc は abnf の文法を yacc に変換しないといけない。yacc で実装した ldap スキーマの parser を公開しているのもみかけたが、yacc は違うなと思って除外した。antlr も abnf から専用の文法に変換しないといけないから不採用にした。

それで消去法的に bnf (ebnf) を扱える gocc で parser をコード生成できないかを調査してみた。しかし、半日ほど bnf を書いて実際にコード生成してみて不採用とした。ebnf の options 記法として [...] に gocc が対応していないようにみえた。この記法がないと ldap スキーマの文法定義が煩雑になる。issue のコメント をみると、lexer は対応したが、parser は対応していないといったコメントがある。コード生成しようとするとエラーになるので未対応なのかもしれない。bnf でこの options 記法相当のものを自分で文法定義すると、1つ2つなら簡単に変換できるが、数個の組み合わせがあると途端に文法の複雑さが大きくなるように思える。abnf から bnf に変換する過程で文法が複雑になってしまうとその後の保守ができなくなる懸念が生じるので断念した。

今回の gocc の採用は却下したが、軽く触ってみて gocc 自体の感触はよかった。シンプルな bnf で表現できるものであれば機会があれば採用してもよいと思える。gocc example をみればわかるが、bnf を書きながら単体テストのコードを実行して動作検証を小さく簡潔にできる。これは parser のコード生成の開発サイクルは速くできそうに感じた。こういう小さく単体で動くツールは好み。

最終的な結論としては parser generator は使わず、自前で parser を実装することを決めた。

case-insensitive という古くて厄介な問題

casemap によるリファクタリング

ldap プロトコル (v3) は rfc 2251 で定義されていて属性へのアクセスは case-insensitive (大文字小文字を区別しない) に扱うという仕様になっている。

ldap サーバーへの問い合わせや検索は case-insensitive となるため、ldap エントリーを扱う他システムの処理もすべて case-insensitive にしないと整合性が取れなくて混乱する。これは利用者だけでなく開発者も同様であり、一部が case-insensitive なのに、一部を case-sensitive (大文字小文字を区別する) にすると、比較処理がマッチしたりしなかったりという混乱を生じる。そういう面倒くさい issue が過去にいくつか散見されてきた。この問題は場当たり的な修正ではなく、本質的な対応が求められた。

結論としては、すべてを case-insensitive に扱う必要があって、そのためにキーを case-insensitive として扱う map like なデータ構造を実装した。オリジナルのキー情報も保持しつつ、case-insensitive にアクセスできるよう小文字変換しておいた名前変換テーブルを内部にもつ。最初からこういったデータ構造を定義しておけば開発時に混乱は起きないが、こういうものは後になってわかってくる。うちは1年半経ってからこの問題の本質を理解した。そのため、関連する処理のインターフェースを見直して置き換えることになった。こういうリファクタリングは時間がかかる割に成果も地味なので労力に対して評価されないことが多い。私もやっていてあまり楽しくはないが、こういうところをきっちり作ると品質に寄与するのを経験的に知っている。

type CaseMap struct {
	keyValue map[string][]string
	nameConv map[string]string
}

func (m *CaseMap) String() string {
	return fmt.Sprintf("%v", m.keyValue)
}

func (m *CaseMap) Values(name string) []string {
	origName, ok := m.nameConv[strings.ToLower(name)]
	if !ok {
		return nil
	}
	values, ok := m.keyValue[origName]
	if !ok {
		return nil
	}
	return values
}

func (m *CaseMap) Get(name string) ([]string, bool) {
	origName, ok := m.nameConv[strings.ToLower(name)]
	if !ok {
		return nil, false
	}
	values, ok := m.keyValue[origName]
	return values, ok
}

func (m *CaseMap) Set(name string, values []string) {
	m.keyValue[name] = values
	m.nameConv[strings.ToLower(name)] = name
}

func (m *CaseMap) Len() int {
	return len(m.keyValue)
}

func (m *CaseMap) Iter() iter.Seq2[string, []string] {
	return func(yield func(string, []string) bool) {
		for k, v := range m.keyValue {
			if !yield(k, v) {
				return
			}
		}
	}
}

type CaseName struct {
	Original  string
	LowerName string
}

func (m *CaseMap) IterWithLower() iter.Seq2[CaseName, []string] {
	return func(yield func(CaseName, []string) bool) {
		for lowerName, origName := range m.nameConv {
			name := CaseName{
				Original:  origName,
				LowerName: lowerName,
			}
			if !yield(name, m.keyValue[origName]) {
				return
			}
		}
	}
}

func (m *CaseMap) ToMap() map[string][]string {
	return m.keyValue
}

func New(length int) *CaseMap {
	return &CaseMap{
		keyValue: make(map[string][]string, length),
		nameConv: make(map[string]string, length),
	}
}

func NewFrom(attr map[string][]string) *CaseMap {
	m := New(len(attr))
	for k, v := range attr {
		m.Set(k, v)
	}
	return m
}

よく働きよく動く

今日の運動は腕立て,スクワット,縄跳び(両足跳),散歩をした。統計を 運動の記録 にまとめる。

ou エントリーの更新対応

ここ2週間ほどずっと、お仕事のある機能開発で テンパった開発状況 でやってきたものがようやく終わった。私は自社の経営だったり、プロジェクトのマネージャーだったりで、普通の開発メンバーのような集中した開発を時間的にできないものの、それでも私が1つの機能に2週間もかける開発はそうそうない。想定以上にこの対応のために、扱う LDAP エントリーのデータ定義や既存システムの実装変更を余儀なくされた。逆に言えば、既存の実装にそういった考慮が当初の設計に足りなかったと言える。この改修において ID 連携におけるノウハウをメンバーと共有したとも言える。

運用視点からは出来て当然の、しかし、開発視点からは厄介な仕様変更の開発を完了できた。いまの時点では完璧に作ったので将来的な拡張も含めて、さまざまな要件に対応できる設計になったと思う。自画自賛だが、時間をかけた分のよいものができたと思う。

みなとのもりの運動

今週の前半は雨降りだったり、雨が断続的に降ったりやんだりでお出掛けできなかった。お仕事も一区切りついた開放感と久しぶりによい天気だったのもあって みなとのもり公園 へ行って縄跳びしてきた。運動できない日々が続いて体力を回復していたせいか、15分間のワークアウトで過去最高の 1113 回となった。前回よりも大きく回数が増えたので、もしかしたら100ほど計測ミスがあるのかもしれない。いまの目標としては15分間で最低800回跳ぶことを想定している。跳んでいるうちにカラダが慣れてきて、ミスしなくなったり、フロー状態に入って速く巧く跳べて回数が増えたりする。

縄跳びの前後にストレッチをできるよう、大きめのレジャーシートも買った。100円ショップなどで売っているレジャーシートに比べたら少し厚手になる。折り畳んで持ち運びできるのがいいなと思って購入した。公園へ持っていくのに便利。1人なら完全に寝転がって広々とストレッチできる。よいと思う。

4月1日の月曜日

23時頃に寝て4時に起きて6時に起きた。変な寝方した。私はわりと月曜日は好き。休み明けで元気だから、世の中的には逆かもしれないが。月曜日に加えて、今日は4月1日で新年度とあってさらに新鮮な気持ちになって晴れ晴れしい。

今日の運動は腹筋ローラー,腕立て,縄跳び(両足跳)をした。統計を 運動の記録 にまとめる。

go-ldap のコントリビュート

過去に私がコントリビュートした非同期検索の機能のなかで context によるキャンセルと channel の操作のところで効率のよい方法を提案してくれた方がいた。私が開発していて気付かなかったところをリファクタリングした。指摘されればその通りだと思う。開発していて non-blocking な select 文を書くことに気を取られているとその認識が漏れていたといった程度のリファクタリングになる。ちょうどいま go-ldap ライブラリを使うアプリケーションのデバッグをやっていて、特定のケースにおいてブロックしてしまう現象に遭遇していて、この context と channel の select 文の扱いが参考になった。issue をあげてくれた方に感謝。

アウトプットがない開発者への対応

お手伝い先でフルタイムの新規開発者が1ヶ月たっても機能開発の issue を1つも fix できていない。svelte で新規の一覧画面を作ってもらう、相対的に簡単な issue なのだけども1ヶ月かけてマージリクエストを出せるレベルにはなっていない。そして今日は休暇をとっている。普通の開発者の感覚として、どんな理由があろうと1ヶ月で issue を1つも fix できていない状況は異常だと思う。ほかメンバーだとそれぞれ16と8つの issue を fix している。もちろん開発者によってやっていることや取り組んでいることの複雑さが異なるため、単純に数が多ければよいというわけでもない。そして内容については私が課題管理を介して把握しているので他のメンバーの働き方になんの問題もない。どんな issue に取り組んでいても1週間に1つも fix できないという状況にはなっていない。1ヶ月かけて開発の issue を1つも fix できないというのがどれだけ異常な状況かはわかると思う。明日から出張で経営陣と進捗について話すときに大きな問題の1つとして共有して対策を検討する必要がある。

致命的バグをみつけた

2時頃に寝て5時過ぎに起きた。お風呂に入るときに fitbit を外して、その後付けるの忘れて寝てしまったから睡眠時間を計測できなかった。

今日の運動はレッグレイズ(椅子),腹筋ローラー,腕立て,スクワットをした。統計を 運動の記録 にまとめる。

agent の致命的なバグ

先週から QA テストをしていて agent の致命的なバグに気付いた。もともとあった java 製の agent から私が設計して作り直した go 製の agent になる。ldap エントリーの更新リクエストのテストツール を作ったことでシビアなタイミングによるバグを検出できた。

ツールを使ってテストしていて、成功するはずのリクエストが失敗して、ログを調査しながらデバッグしていた。ldap エントリーを扱う難しさの1つに、ldap サーバーはエントリー間の依存関係やデータの整合性といったものを検証しない。そういった用途はアプリケーションの役割であって、ldap プロトコルはあくまで id を管理することに特化したものという役割分担になっているのだと推測する。そうすると、(open) ldap サーバーへ登録できたエントリーが次の agent や api サーバーといったアプリケーションのレイヤーでエラーになることがある。このとき、ldap サーバーではエラーが発生していないため、直接的なエラーを検知することができなくて、デバッグや調査に時間がかかる。

agent の実装として、ユーザーエントリのストリームとグループエントリーのストリームの2つに分割して、非同期にそれぞれのエントリーを id 連携する設計にしていた。というのは、ldap エントリーにはユーザーやグループといった概念は原則として存在しない。そのエントリーがユーザーなのか、グループなのかはアプリケーションが判断している。アプリケーションの用途としてはこの2つを明確にわけないと不便なことから、ワークアラウンドもしくは実務的な解決策として検索するときのフィルターと base dn で管理するようにしていた。そして、これらをそれぞれ別のストリームとして扱うよう、私が設計していた。このことがタイミングによってはユーザーエントリーとグループエントリーに依存関係がある場合、データの整合性を保証できないことに気付いた。なぜならば、ユーザーエントリーとグループエントリーそれぞれ非同期/並行に処理されてしまうから。

結論としては、ldap サーバーからエントリーの更新の順序を保証するには1つのストリームを subscribe しないといけない。そして、ストリームから取り出したエントリーがユーザーなのか、グループなのかはアプリケーションが判別しないといけない。テストツールを作ったことでシビアなバグも検出できた。

定額減税

定額減税 特設サイト が公開されたらしいというニュースをあちこちでみつける。従業員の税金が安くなるので経営者としての私がなにかしないといけないと思っているけれども、まだ何をしていいのかよくわかっていない。時間をみつけて調べないといけない。税制が変わるとこういった事務手続きが突発的に入ってくるのがマイクロ法人の面倒なところ。

ホットクックのレシピを upnote で書く

2時半に寝て5時過ぎに起きた。それから二度寝して7時に起きた。

今日の運動はレッグレイズ(椅子),腹筋ローラー,腕立て,散歩をした。統計を 運動の記録 にまとめる。

トランクルームの契約

家電を購入すると大きな箱が付いてきて物理的にその置き場所がない。箱を捨てるという戦略もあるが、オリジナルの箱があると引っ越しや売買/処分するときに便利なのでできれば残しておきたい。これまでデスクトップマシンの箱やオフィス備え付けの椅子などをマンションの部屋に保管したりしていたけど、家電の箱が増えてくると邪魔になってきて、トランクルームを借りることにした。面倒なのであまり調べていないが、スペラボ というサービスの屋内型トランクルームをレンタルすることにした。0.7畳で6,450円/月(税込)になる。会社の経費だしこのぐらいの金額ならいいかと思って、朝から内見に行って問題なさそうなので即決した。

ホットクックレシピの公開

ホットクックのレシピをどこかに整理したい。スマホ上でも調理しながら簡単に確認したいとなると web よりもアプリの方がよい。そこで evernote から移行した upnote に書くことにした。実際に調理してみて、デスクトップマシンでレシピを編集・整理して、写真はスマホアプリからアップロードするといった使い方ができる。View shared notes によると、ノート単位で web 公開もできる。試しに次のレシピを公開してみた。ノートブック単位で公開設定して一覧ページがあるともっとよいが、その機能はないみたい。公開リンクを作成する一手間はあるけれど、そんなにレシピノートを書くわけでもないから気にはならない。

ホットクック調理は材料入れてボタン押したら終わりではないか?と思うかもしれない。いや、そうでもないようだ。たしかに材料を内鍋に入れてボタン押したら人間ができることは何もない。だからこそ、ボタンを押す前の過程が大事になってくる。どんな切り方をするのか、素材のサイズはどうか、初期配置はどうか。例えば、豚ロース肉の生姜焼き用を買ってきて、適当に切って3枚4枚重なった状態で投入したらそのままの状態で出来上がった。かき混ぜ棒があるからうまいこと豚肉もバラけるだろうと期待したが、ぴったりくっついているようなお肉をバラかすほどのパワーはないようだ。人間が最初からバラかした状態で内鍋に投入すると、出来上がりのときに豚肉のかたまりがなくなってよいと思う。

あと気付いたこととして、野菜もお肉も素材を少し小さめに切った方がおいしいように感じる。というのは、ホットクックは圧力鍋ほどの火力で調理しない。圧力鍋に慣れていると、少し大きめに切っても原形がなくなって溶けてしまったり、出来上がり時点で原形があっても混ぜたりしているうちに角がとれて、どんどん小さくなっていく。小さくならなくても口の中でとろけるので大きいままでも問題ない。相対的にホットクックはそこまでの火力はないから、小さめで味が染み込むような出来上がりになるため、小さめに切っても原形は保ったままで溶けてなくなってしまうことはない。これは良い悪いではなくて、それぞれの製品の特徴と言えるだろう。ホットクック調理は素材を小さめに切るというのが、いまところ、私が作ったレシピではうまくいっている。ホットクックでは、小さく切った複数の素材をほお張って食べるのがおいしい。

go-ldap への context 導入の考察

go-ldap の issue は subscribe しているので、たまたま initial cut of context support #406 の draft pr のコメントに気付いた。過去に context を導入しようとして途中で断念した残骸みたいな pr になっている。いまの go の api は context を受け取るのが当たり前になっているので確かにほしいというのは理解できる。

代わりに私がやってみようかとソースコードを読んでみた。オリジナルの draft pr は client の interface に WithContext なメソッドを追加しようというもの。go の context の扱いとしてもっとも基本的なもの。それでもよいけれど、net/http の実装はどうなっているのかを調べていたら Request 構造体のメンバーとして context を保持していることがわかった。このやり方は原則のルールに反するものだけど、context を状態として扱わず、限定的な用途にのみ使っている。これは既存の interface を変えずに context 導入をしようという意図があると推測する。この考え方でいくと、go-ldap の Request 構造体に context を保持するように変更する方が既存の API の変更を少なくして net/http の Request も同様にやっているからと説明もしやすいように思えた。

type Request struct {
...
	// ctx is either the client or server context. It should only
	// be modified via copying the whole Request using Clone or WithContext.
	// It is unexported to prevent people from using Context wrong
	// and mutating the contexts held by callers of the same request.
	ctx context.Context
...
}

今日のところは go-ldap と net/http のソースコードを読んで設計をしていた。また明日余裕があったらサンプル実装してみる。

ローカルに window server をインストールした

1時過ぎに寝て6時半に起きた。久しぶりによく眠れた。6時半に起きてたのに8時ぐらいまでだらだらしてた。

今日の筋トレは腹筋:20x1,腕立て:15x1,スクワット20x1をした。

windows server 2022 試用版のインストール

Windows Server 2022 の試用版が提供されていると知ったのでローカルの VirtualBox 環境にインストールしてみた。Active Directory の検証に使うのでディレクトサービスや ldaps 接続のための証明書サービスなどを設定する。メンバーがインストールして課題管理システムにメモを残してくれてあったのでそれを見ながらインストールや設定自体はすぐにできた。1つだけうまく接続できないことがあった。

ホスト os から ldapsearch で ldaps で接続しようとすると次のようなエラーになる。

$ ldapsearch -x -H "ldaps://192.168.56.101" -W -b "CN=Users,DC=myad,DC=com" -D "CN=Administrator,CN=Users,DC=myad,DC=com"
ldap_sasl_bind(SIMPLE): Can't contact LDAP server (-1)

ldap では接続できるので tls の検証のチェックに失敗していることは自明だったが、メンバーは接続できているようにみえたので私の環境の設定が誤っているのかどうかを調べていた。2時間ぐらいデバッグしたりしながら調べてもよくわからなくて、たまたまチーム勉強会があったので終わったときにメンバーに聞いてみた。すぐに完結した。ldapsearch は LDAPTLS_REQCERT という環境変数で tls のリクエストの振る舞いを制御できる。次のように明示的に指定すると接続できる。

$ LDAPTLS_REQCERT=never ldapsearch -x -H "ldaps://192.168.56.101" ...

この設定はいくつかの方法で設定ファイルに書いておくこともできる。メンバーが使っている環境には ldap.conf でこの設定を有効にしていたので ldaps 接続できていたというオチだった。私が openldap について明るくないのでこういった背景知識をもっていなくてはまっていただけだった。

Thus the following files and variables are read, in order:
    variable     $LDAPNOINIT, and if that is not set:
    system file  /etc/openldap/ldap.conf,
    user files   $HOME/ldaprc,  $HOME/.ldaprc,  ./ldaprc,
    system file  $LDAPCONF,
    user files   $HOME/$LDAPRC, $HOME/.$LDAPRC, ./$LDAPRC,
    variables    $LDAP<uppercase option name>.
Settings late in the list override earlier ones.

明石海峡大橋海上ウォーク

神戸ジャーナルの記事をみかけて、そういうイベントがあるのは知っていた。

開発合宿の翌週だし、わざわざ行くほどでもないかとスルーしていたものの、関東から知人がわざわざ歩きに来るという話しを聞いて、せっかくの機会なので私も参加することにした。土曜日の午前中に明石海峡大橋を歩いてくる。まだ申込みが始まったばかりなので希望の時間帯を選択できると思う。

生活リズムが崩れた月曜日

1時に寝て2時半に起きて5時に起きて7時に起きた。朝から昼過ぎまで寝てたのでトータルでは睡眠時間をたくさん取っているのになんか疲れている。生活のリズムを崩すのがよくないのかも。

ガンダムに学ぶ経営学

教えてもらって寝る前にみたらおもしろかった。入山先生がガンダム大好きなことが伝わってくる。おもしろいのはそうだけど、まじレスすると、アニメの世界の組織や経済に学ぶというのは誤りで、当時の組織や経済のリアルを参考にして、ガンダムの世界観は作られていると推測する。だから入山先生はユーモアで盛り上げているのだと思うけれど、ガンダムから学ぶのではなく歴史から学べが正解だと思う。でも、ガンダムの世界観を知るよい番組だと思う。

go-ldap へのプルリクエスト

2週間ほど前に送った pr がまだレビューすらしてもらえていない。github actions のテストがいくつか落ちていて、この pr の修正によるものではないところでエラーになっている。メンテナーの1人が再実行してくれたんだけど、たくさんあるマトリックステストのどこかが落ちてまた再実行しないといけない。

それを待っている間に、テストがエラーになる本当の原因の問題を直そうと2-3日前に issue 登録していた。この issue の対応を本当は昨日やろうと思っていたのに、思いの外、寝てしまって、その後もいろいろ書きものをしていて時間を使ってしまってできていなかった。今朝からそれを片付けた。業務の一環なので日曜日にプルリクエストを送らなくてもよいのだけど、コントリビューションなので空き時間に終わらせて、業務の時間は別のことに使いたいという思いもあったりする。

さらに github actions のログに deprecated ワーニングが出ていたのでついでにそれも直した。

473, 474, 471 の順番にマージされていくのが望ましい。そろそろコミット権をくれたりしないかな?と思ったりもする。というのは、タイミングの問題で action の job が落ちたり、バージョン上げるだけの pr とか、自分でマージしてしまえばいいと思ったりする。

hugo で書いた記事に目次を生成する

お手伝い先のテックブログは hugo で運用されている。記事の目次がないなと気付いて追加してみた。hugo v0.60.0 以降のバージョンなら標準で目次生成の機能をもっている。

<aside>
  {{ .TableOfContents }}
</aside>

デフォルトはヘッダー2レベルより下の目次を生成する。レベル1も生成したい場合は config.toml に次の設定を追加する。

[markup]
  [markup.tableOfContents]
    startLevel = 1
    endLevel = 3